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 change
while experimental. Set to ["multi-runner-v2"] when using the v2
provider-boundary configuration. This flag will become a deprecated no-op
for one release when the feature graduates. | `set(string)` | `[]` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | -| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | +| [github\_app](#input\_github\_app) | GitHub app parameters for the stable v1 interface, see your github app.
Omit this value when using the experimental v2 interface and provide the
app through `global_config_github` instead.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| `{}` | no | | [global\_config](#input\_global\_config) | Global defaults shared by all runner lanes.

global\_config = {
tags: "Tags applied to resources created for all runner lanes."
roles: {
path: "IAM path used for roles created for runner resources."
permissions\_boundary: "Optional IAM permissions boundary ARN applied to created roles."
}
runner: {
os: "Default operating system for runners."
architecture: "Default runner architecture."
disable\_default\_labels: "Whether to omit the default operating-system, architecture, and self-hosted labels."
extra\_labels: "Additional labels applied to all runners."
group\_name: "Default GitHub runner group."
name\_prefix: "Prefix for runner names."
run\_as\_root: "Whether the GitHub Actions runner executes as root."
run\_as: "User that runs the GitHub Actions agent when it is not running as root."
auto\_update\_disabled: "Whether automatic GitHub Actions runner updates are disabled."
tags: "Tags applied to runner resources."
hooks: {
job\_started: "Script executed when a job starts on a runner."
job\_completed: "Script executed when a job completes on a runner."
}
iam: {
role.arn: "Existing IAM role ARN to use for runners."
managed\_policy\_arns: "Managed policy ARNs attached to the runner IAM role."
additional\_trust\_policy\_json: "Additional trust policy JSON merged into the runner role trust policy."
path: "IAM path used for the runner role."
permissions\_boundary: "Optional IAM permissions boundary ARN for the runner role."
}
}
} |
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.

global\_config\_compute\_provider = {
selections: "Compute-provider selections keyed by namespace."
selections.namespace: "Provider namespace used to resolve a compute implementation."
selections.type: "Compute-provider type selected for the namespace."
aws.ec2.vpc\_id: "Default VPC for EC2 runners."
aws.ec2.subnet\_ids: "Default subnets for EC2 runners."
aws.ec2.managed\_security\_group\_enabled: "Whether the module manages the default runner security group."
aws.ec2.egress\_rules: "Egress rules for the managed runner security group."
aws.ec2.egress\_rules.cidr\_blocks: "IPv4 CIDR blocks allowed by an egress rule."
aws.ec2.egress\_rules.ipv6\_cidr\_blocks: "IPv6 CIDR blocks allowed by an egress rule."
aws.ec2.egress\_rules.prefix\_list\_ids: "AWS prefix lists allowed by an egress rule."
aws.ec2.egress\_rules.from\_port: "Start of the egress port range."
aws.ec2.egress\_rules.protocol: "Protocol for the egress rule."
aws.ec2.egress\_rules.security\_groups: "Referenced security groups allowed by an egress rule."
aws.ec2.egress\_rules.self: "Whether the security group itself is allowed by an egress rule."
aws.ec2.egress\_rules.to\_port: "End of the egress port range."
aws.ec2.egress\_rules.description: "Description of the egress rule."
aws.ec2.additional\_security\_group\_ids: "Additional security groups attached to EC2 runners."
aws.ec2.cloudwatch\_agent.config: "CloudWatch Agent configuration for EC2 runners."
aws.ec2.instance\_profile\_path: "IAM path used for the EC2 instance profile."
aws.ec2.key\_name: "EC2 key pair name assigned to runner instances."
aws.ec2.associate\_public\_ipv4\_address: "Whether runner instances receive a public IPv4 address."
aws.ec2.tags: "Tags applied to EC2 runner resources."
aws.ec2.ami.housekeeper.enabled: "Whether AMI cleanup is enabled."
aws.ec2.ami.housekeeper.cleanup\_config.maxItems: "Maximum number of AMIs retained by cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.minimumDaysOld: "Minimum AMI age in days before cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.amiFilters: "AMI filters used to select AMIs for cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.amiFilters.Name: "AMI filter name."
aws.ec2.ami.housekeeper.cleanup\_config.amiFilters.Values: "Values matched by the AMI filter."
aws.ec2.ami.housekeeper.cleanup\_config.launchTemplateNames: "Launch template names associated with AMIs eligible for cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.ssmParameterNames: "SSM parameter names associated with AMIs eligible for cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.dryRun: "Whether AMI cleanup reports changes without deleting AMIs."
aws.ec2.ami.housekeeper.artifact.zip: "Local ZIP artifact used for the AMI housekeeper Lambda."
aws.ec2.ami.housekeeper.artifact.s3.key: "S3 object key for the AMI housekeeper Lambda artifact."
aws.ec2.ami.housekeeper.artifact.s3.object\_version: "Optional S3 object version for the AMI housekeeper artifact."
aws.ec2.ami.housekeeper.lambda.memory\_size: "Memory allocated to the AMI housekeeper Lambda."
aws.ec2.ami.housekeeper.lambda.timeout: "Timeout in seconds for the AMI housekeeper Lambda."
aws.ec2.ami.housekeeper.schedule.expression: "Schedule expression for AMI cleanup."
aws.ec2.instance\_termination\_watcher.enabled: "Whether the instance termination watcher is enabled."
aws.ec2.instance\_termination\_watcher.features.runner\_deregistration.enabled: "Whether terminated runners are deregistered."
aws.ec2.instance\_termination\_watcher.features.spot\_termination\_handler.enabled: "Whether spot termination events trigger runner handling."
aws.ec2.instance\_termination\_watcher.features.spot\_termination\_notification\_watcher.enabled: "Whether spot termination notification monitoring is enabled."
aws.ec2.instance\_termination\_watcher.environment\_variables: "Environment variables passed to the termination watcher."
aws.ec2.instance\_termination\_watcher.artifact.zip: "Local ZIP artifact used for the termination watcher Lambda."
aws.ec2.instance\_termination\_watcher.artifact.s3.key: "S3 object key for the termination watcher Lambda artifact."
aws.ec2.instance\_termination\_watcher.artifact.s3.object\_version: "Optional S3 object version for the termination watcher artifact."
aws.ec2.instance\_termination\_watcher.lambda.memory\_size: "Memory allocated to the termination watcher Lambda."
aws.ec2.instance\_termination\_watcher.lambda.timeout: "Timeout in seconds for the termination watcher Lambda."
aws.ec2.runner\_binaries.enabled: "Whether runner binary synchronization is enabled."
aws.ec2.runner\_binaries.s3.encryption.enabled: "Whether runner-binary S3 encryption is enabled."
aws.ec2.runner\_binaries.s3.encryption.bucket\_key\_enabled: "Whether an S3 bucket key is used for KMS encryption."
aws.ec2.runner\_binaries.s3.encryption.sse\_algorithm: "S3 server-side encryption algorithm."
aws.ec2.runner\_binaries.s3.encryption.kms\_master\_key\_id: "KMS key ID used for runner-binary S3 encryption."
aws.ec2.runner\_binaries.s3.tags: "Tags applied to the runner-binary S3 bucket."
aws.ec2.runner\_binaries.s3.versioning: "S3 versioning state for the runner-binary bucket."
aws.ec2.runner\_binaries.s3.logging.bucket: "S3 bucket receiving runner-binary access logs."
aws.ec2.runner\_binaries.s3.logging.prefix: "Prefix for runner-binary S3 access logs."
aws.ec2.runner\_binaries.syncer.artifact.zip: "Local ZIP artifact used for the runner-binary syncer Lambda."
aws.ec2.runner\_binaries.syncer.artifact.s3.key: "S3 object key for the runner-binary syncer artifact."
aws.ec2.runner\_binaries.syncer.artifact.s3.object\_version: "Optional S3 object version for the runner-binary syncer artifact."
aws.ec2.runner\_binaries.syncer.lambda.memory\_size: "Memory allocated to the runner-binary syncer Lambda."
aws.ec2.runner\_binaries.syncer.lambda.timeout: "Timeout in seconds for the runner-binary syncer Lambda."
aws.ec2.runner\_binaries.syncer.schedule.expression: "Schedule expression for runner-binary synchronization."
aws.ec2.runner\_binaries.syncer.schedule.state: "EventBridge rule state for runner-binary synchronization."
} |
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.

global\_config\_github = {
app: {
key\_base64: "Base64-encoded GitHub App private key."
key\_base64\_ssm: "SSM parameter containing the Base64-encoded GitHub App private key."
key\_base64\_ssm.arn: "ARN of the SSM parameter containing the GitHub App private key."
key\_base64\_ssm.name: "Name of the SSM parameter containing the GitHub App private key."
id: "GitHub App ID."
id\_ssm: "SSM parameter containing the GitHub App ID."
id\_ssm.arn: "ARN of the SSM parameter containing the GitHub App ID."
id\_ssm.name: "Name of the SSM parameter containing the GitHub App ID."
webhook\_secret: "GitHub App webhook secret."
webhook\_secret\_ssm: "SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.arn: "ARN of the SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.name: "Name of the SSM parameter containing the GitHub App webhook secret."
}
additional\_apps: "Additional GitHub Apps used to distribute GitHub API requests."
additional\_apps.key\_base64: "Base64-encoded private key for an additional GitHub App."
additional\_apps.key\_base64\_ssm: "SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.arn: "ARN of the SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.name: "Name of the SSM parameter containing an additional App private key."
additional\_apps.id: "ID of an additional GitHub App."
additional\_apps.id\_ssm: "SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.arn: "ARN of the SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.name: "Name of the SSM parameter containing an additional GitHub App ID."
additional\_apps.installation\_id: "Optional installation ID for an additional GitHub App."
additional\_apps.installation\_id\_ssm: "SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.arn: "ARN of the SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.name: "Name of the SSM parameter containing an additional App installation ID."
enterprise\_server.url: "GitHub Enterprise Server URL."
enterprise\_server.ssl\_verify: "Whether to verify the GitHub Enterprise Server TLS certificate."
user\_agent: "User-Agent value sent with GitHub API requests."
} |
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.

global\_config\_lambda = {
artifact.s3.bucket: "S3 bucket containing Lambda deployment artifacts."
runtime: "Default Lambda runtime."
architecture: "Default Lambda instruction-set architecture."
principals: "Additional AWS principals allowed to invoke the Lambda functions."
principals.type: "Principal type, such as AWS account, service, or organization."
principals.identifiers: "Identifiers allowed for the principal type."
subnet\_ids: "Subnets used by Lambda functions."
security\_group\_ids: "Security groups attached to Lambda functions."
tags: "Tags applied to Lambda functions and related resources."
role.path: "IAM path used for Lambda execution roles."
role.permissions\_boundary: "Optional IAM permissions boundary ARN for Lambda execution roles."
} |
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.

global\_config\_observability = {
logs.level: "Log level for module resources."
logs.retention\_in\_days: "CloudWatch log retention period in days."
logs.kms\_key\_id: "KMS key ID used to encrypt CloudWatch log groups."
logs.class: "CloudWatch log group class."
logs.tags: "Tags applied to CloudWatch log groups."
tracing.mode: "Tracing mode used by instrumented resources."
tracing.capture\_http\_requests: "Whether HTTP requests are captured by tracing."
tracing.capture\_error: "Whether errors are captured by tracing."
metrics.enabled: "Whether module metrics are enabled."
metrics.namespace: "CloudWatch namespace used for module metrics."
metrics.metric.github\_app\_rate\_limit.enabled: "Whether GitHub App rate-limit metrics are emitted."
metrics.metric.job\_retry.enabled: "Whether job-retry metrics are emitted."
metrics.metric.spot\_termination\_warning.enabled: "Whether spot-termination warning metrics are emitted."
} |
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.

global\_config\_orchestration\_provider = {
webhook: {
queue\_selection\_strategy: "Strategy used to select the build queue for a webhook event."
eventbridge.enabled: "Whether EventBridge integration is enabled for webhook events."
eventbridge.accept\_events: "Event types accepted by the EventBridge integration."
matcher\_config\_parameter\_store\_tier: "SSM Parameter Store tier used for matcher configuration."
runner.boot\_time\_in\_minutes: "Expected runner boot time used by orchestration."
runner.ephemeral: "Whether runners created by the orchestration provider are ephemeral."
runner.jit\_config\_enabled: "Whether JIT runner configuration is enabled."
runner.maximum\_count: "Maximum number of runners that orchestration may create."
github.repository\_white\_list: "Repositories allowed to use the webhook configuration."
lambda.artifact.zip: "Local ZIP artifact used for orchestration Lambda functions."
lambda.artifact.s3.key: "S3 object key for the orchestration Lambda artifact."
lambda.artifact.s3.object\_version: "Optional S3 object version for the orchestration Lambda artifact."
lambda.scale.up.memory\_size: "Memory allocated to the scale-up Lambda."
lambda.scale.up.timeout: "Timeout in seconds for the scale-up Lambda."
lambda.scale.up.reserved\_concurrent\_executions: "Reserved concurrent executions for the scale-up Lambda."
lambda.scale.up.job\_queued\_check\_enabled: "Whether the scale-up Lambda checks queued jobs."
lambda.scale.up.event\_source\_mapping.batch\_size: "Maximum records passed to one scale-up Lambda invocation."
lambda.scale.up.event\_source\_mapping.maximum\_batching\_window\_in\_seconds: "Maximum time to batch records before invoking the scale-up Lambda."
lambda.scale.up.tags: "Tags applied to the scale-up Lambda."
lambda.scale.down.memory\_size: "Memory allocated to the scale-down Lambda."
lambda.scale.down.timeout: "Timeout in seconds for the scale-down Lambda."
lambda.scale.down.schedule\_expression: "Schedule expression for scale-down processing."
lambda.scale.down.minimum\_running\_time\_in\_minutes: "Minimum runner lifetime before scale-down."
lambda.scale.down.idle\_config: "Scheduled minimum idle-runner pool settings."
lambda.scale.down.idle\_config.cron: "Cron expression defining when the idle-runner count applies."
lambda.scale.down.idle\_config.timeZone: "Time zone used to evaluate the idle-runner schedule."
lambda.scale.down.idle\_config.idleCount: "Minimum number of idle runners maintained during the schedule."
lambda.scale.down.idle\_config.evictionStrategy: "Strategy used when evicting idle runners."
lambda.scale.down.tags: "Tags applied to the scale-down Lambda."
lambda.webhook.artifact.zip: "Local ZIP artifact used for the webhook Lambda."
lambda.webhook.artifact.s3.key: "S3 object key for the webhook Lambda artifact."
lambda.webhook.artifact.s3.object\_version: "Optional S3 object version for the webhook Lambda artifact."
lambda.webhook.api\_gateway\_access\_log\_settings: "API Gateway access-log destination and format."
lambda.webhook.api\_gateway\_access\_log\_settings.destination\_arn: "ARN of the API Gateway access-log destination."
lambda.webhook.api\_gateway\_access\_log\_settings.format: "API Gateway access-log format."
lambda.webhook.memory\_size: "Memory allocated to the webhook Lambda."
lambda.webhook.timeout: "Timeout in seconds for the webhook Lambda."
lambda.webhook.tags: "Tags applied to the webhook Lambda."
lambda.pool.memory\_size: "Memory allocated to the pool Lambda."
lambda.pool.timeout: "Timeout in seconds for the pool Lambda."
lambda.pool.reserved\_concurrent\_executions: "Reserved concurrent executions for the pool Lambda."
lambda.pool.config: "Scheduled runner-pool size configuration."
lambda.pool.config.schedule\_expression: "Schedule expression for the pool size."
lambda.pool.config.schedule\_expression\_timezone: "Time zone used to evaluate the pool schedule."
lambda.pool.config.size: "Runner pool size applied by the schedule."
lambda.pool.include\_busy\_runners: "Whether busy runners are included in pool sizing."
lambda.pool.runner\_owner: "GitHub organization that owns the runner pool."
lambda.pool.tags: "Tags applied to the pool Lambda."
queue.delay\_webhook\_event: "Seconds a webhook event remains invisible in the build queue before processing."
queue.job\_queue\_retention\_in\_seconds: "Seconds a queued job is retained before it is purged."
queue.visibility\_timeout\_seconds: "Build queue visibility timeout in seconds."
queue.redrive\_build\_queue.enabled: "Whether the build queue dead-letter queue is enabled."
queue.redrive\_build\_queue.maxReceiveCount: "Maximum receives before a message is moved to the dead-letter queue."
queue.tags: "Tags applied to build queues."
queue.encryption.kms\_data\_key\_reuse\_period\_seconds: "KMS data-key reuse period for queue encryption."
queue.encryption.kms\_master\_key\_id: "KMS key ID used for queue encryption."
queue.encryption.sqs\_managed\_sse\_enabled: "Whether SQS-managed server-side encryption is enabled."
}
} |
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.

global\_config\_orchestration\_provider = {
webhook: {
queue\_selection\_strategy: "Strategy used to select the build queue for a webhook event."
eventbridge.enabled: "Whether EventBridge integration is enabled for webhook events."
eventbridge.accept\_events: "Event types accepted by the EventBridge integration."
matcher\_config\_parameter\_store\_tier: "SSM Parameter Store tier used for matcher configuration."
runner.boot\_time\_in\_minutes: "Expected runner boot time used by orchestration."
runner.ephemeral: "Whether runners created by the orchestration provider are ephemeral."
runner.jit\_config\_enabled: "Whether JIT runner configuration is enabled."
runner.maximum\_count: "Maximum number of runners that orchestration may create."
github.repository\_white\_list: "Repositories allowed to use the webhook configuration."
lambda.artifact.zip: "Local ZIP artifact used for orchestration Lambda functions."
lambda.artifact.s3.key: "S3 object key for the orchestration Lambda artifact."
lambda.artifact.s3.object\_version: "Optional S3 object version for the orchestration Lambda artifact."
lambda.scale.up.memory\_size: "Memory allocated to the scale-up Lambda."
lambda.scale.up.timeout: "Timeout in seconds for the scale-up Lambda."
lambda.scale.up.reserved\_concurrent\_executions: "Reserved concurrent executions for the scale-up Lambda."
lambda.scale.up.job\_queued\_check\_enabled: "Whether the scale-up Lambda checks queued jobs."
lambda.scale.up.event\_source\_mapping.batch\_size: "Maximum records passed to one scale-up Lambda invocation."
lambda.scale.up.event\_source\_mapping.maximum\_batching\_window\_in\_seconds: "Maximum time to batch records before invoking the scale-up Lambda."
lambda.scale.up.tags: "Tags applied to the scale-up Lambda."
lambda.scale.down.memory\_size: "Memory allocated to the scale-down Lambda."
lambda.scale.down.timeout: "Timeout in seconds for the scale-down Lambda."
lambda.scale.down.schedule\_expression: "Schedule expression for scale-down processing."
lambda.scale.down.minimum\_running\_time\_in\_minutes: "Minimum runner lifetime before scale-down."
lambda.scale.down.idle\_config: "Scheduled minimum idle-runner pool settings."
lambda.scale.down.idle\_config.cron: "Cron expression defining when the idle-runner count applies."
lambda.scale.down.idle\_config.timeZone: "Time zone used to evaluate the idle-runner schedule."
lambda.scale.down.idle\_config.idleCount: "Minimum number of idle runners maintained during the schedule."
lambda.scale.down.idle\_config.evictionStrategy: "Strategy used when evicting idle runners."
lambda.scale.down.tags: "Tags applied to the scale-down Lambda."
lambda.webhook.artifact.zip: "Local ZIP artifact used for the webhook Lambda."
lambda.webhook.artifact.s3.key: "S3 object key for the webhook Lambda artifact."
lambda.webhook.artifact.s3.object\_version: "Optional S3 object version for the webhook Lambda artifact."
lambda.webhook.api\_gateway\_access\_log\_settings: "API Gateway access-log destination and format."
lambda.webhook.api\_gateway\_access\_log\_settings.destination\_arn: "ARN of the API Gateway access-log destination."
lambda.webhook.api\_gateway\_access\_log\_settings.format: "API Gateway access-log format."
lambda.webhook.memory\_size: "Memory allocated to the webhook Lambda."
lambda.webhook.timeout: "Timeout in seconds for the webhook Lambda."
lambda.webhook.tags: "Tags applied to the webhook Lambda."
lambda.pool.memory\_size: "Memory allocated to the pool Lambda."
lambda.pool.timeout: "Timeout in seconds for the pool Lambda."
lambda.pool.reserved\_concurrent\_executions: "Reserved concurrent executions for the pool Lambda."
lambda.pool.config: "Scheduled runner-pool size configuration."
lambda.pool.config.schedule\_expression: "Schedule expression for the pool size."
lambda.pool.config.schedule\_expression\_timezone: "Time zone used to evaluate the pool schedule."
lambda.pool.config.size: "Runner pool size applied by the schedule."
lambda.pool.include\_busy\_runners: "Whether busy runners are included in pool sizing."
lambda.pool.runner\_owner: "GitHub organization that owns the runner pool."
lambda.pool.tags: "Tags applied to the pool Lambda."
queue.delay\_webhook\_event: "Seconds a webhook event remains invisible in the build queue before processing."
queue.job\_queue\_retention\_in\_seconds: "Seconds a queued job is retained before it is purged."
queue.visibility\_timeout\_seconds: "Build queue visibility timeout in seconds."
queue.redrive\_build\_queue.enabled: "Whether the build queue dead-letter queue is enabled."
queue.redrive\_build\_queue.maxReceiveCount: "Maximum receives before a message is moved to the dead-letter queue."
queue.tags: "Tags applied to build queues."
queue.encryption.kms\_data\_key\_reuse\_period\_seconds: "KMS data-key reuse period for queue encryption."
queue.encryption.kms\_master\_key\_id: "KMS key ID used for queue encryption."
queue.encryption.sqs\_managed\_sse\_enabled: "Whether SQS-managed server-side encryption is enabled."
}
} |
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.

global\_config\_ssm = {
paths.root: "Root path for SSM parameters."
paths.app: "Path segment for application parameters."
paths.webhook: "Path segment for webhook parameters."
paths.tokens: "Path segment for runner token parameters."
paths.config: "Path segment for runner configuration parameters."
kms\_key\_id: "KMS key ID used to encrypt SSM parameters."
tags: "Tags applied to SSM resources."
parameters.tags: "Tags applied to runner configuration parameters."
housekeeper.schedule\_expression: "Schedule for the SSM parameter housekeeper."
housekeeper.state: "EventBridge rule state for the SSM parameter housekeeper."
housekeeper.tags: "Tags applied to the SSM housekeeper resources."
housekeeper.lambda.artifact.zip: "Local ZIP artifact used for the SSM housekeeper Lambda."
housekeeper.lambda.artifact.s3.key: "S3 object key for the SSM housekeeper Lambda artifact."
housekeeper.lambda.artifact.s3.object\_version: "Optional S3 object version for the SSM housekeeper artifact."
housekeeper.lambda.memory\_size: "Memory allocated to the SSM housekeeper Lambda."
housekeeper.lambda.timeout: "Timeout in seconds for the SSM housekeeper Lambda."
housekeeper.config.tokenPath: "Parameter path containing runner tokens to clean up."
housekeeper.config.minimumDaysOld: "Minimum age in days before an old token is eligible for cleanup."
housekeeper.config.dryRun: "Whether the SSM housekeeper reports cleanup without deleting parameters."
} |
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.

multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux, osx, windows)."
runner\_architecture: "The platform architecture of the runner instance\_type."
runner\_metadata\_options: "(Optional) Metadata options for the ec2 runner instances."
ami: "(Optional) AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place."
create\_service\_linked\_role\_spot: (Optional) create the serviced linked role for spot instances that is required by the scale-up lambda.
credit\_specification: "(Optional) The credit specification of the runner instance\_type. Can be unset, `standard` or `unlimited`.
delay\_webhook\_event: "The number of seconds the event accepted by the webhook is invisible on the queue before the scale up lambda will receive the event."
disable\_runner\_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)"
ebs\_optimized: "The EC2 EBS optimized configuration."
enable\_ephemeral\_runners: "Enable ephemeral runners, runners will only be used once."
enable\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
instance\_types: "List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
runner\_disable\_default\_labels: "Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config: "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
license\_specifications: "Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
job\_retry: "Experimental! Can be removed / changed without trigger a major release. Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
# V2 contract
tags: "Tags applied to resources created for this runner configuration."
runner: "Runner settings such as the operating system, architecture, labels, hooks, runner group, name prefix, and IAM role configuration."
lambda: "Lambda settings such as runtime, architecture, networking, tags, and execution-role options for this runner configuration."
# Webhook, queue, and scale-up/scale-down orchestration settings.
orchestration\_provider: {
webhook: {
matcherConfig: "Label matching and dynamic-label policy used to route workflow jobs to this runner configuration."
runner: "Runner lifecycle settings including boot time, ephemeral mode, JIT configuration, and maximum runner count."
queue: "Build queue delay, retention, visibility timeout, redrive, and tags."
}
}
ssm: "SSM parameter paths, tags, and housekeeper settings for runner configuration storage."
observability: "Logging, tracing, and metric settings for the resources in this runner configuration."
# Compute settings for the runner provider.
compute\_provider: {
aws: {
ec2: "AWS EC2 runner settings, including AMI selection, instance types, capacity strategy, VPC and subnet placement, storage, user data, and runner access."
}
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
# 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.

multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux, osx, windows)."
runner\_architecture: "The platform architecture of the runner instance\_type."
runner\_metadata\_options: "(Optional) Metadata options for the ec2 runner instances."
ami: "(Optional) AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place."
create\_service\_linked\_role\_spot: (Optional) create the serviced linked role for spot instances that is required by the scale-up lambda.
credit\_specification: "(Optional) The credit specification of the runner instance\_type. Can be unset, `standard` or `unlimited`.
delay\_webhook\_event: "The number of seconds the event accepted by the webhook is invisible on the queue before the scale up lambda will receive the event."
disable\_runner\_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)"
ebs\_optimized: "The EC2 EBS optimized configuration."
enable\_ephemeral\_runners: "Enable ephemeral runners, runners will only be used once."
enable\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
instance\_types: "List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
runner\_disable\_default\_labels: "Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config: "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
license\_specifications: "Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
job\_retry: "Experimental! Can be removed / changed without trigger a major release. Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
# V2 contract
tags: "Tags applied to resources created for this runner configuration."
runner: "Runner settings such as the operating system, architecture, labels, hooks, runner group, name prefix, and IAM role configuration."
lambda: "Lambda settings such as runtime, architecture, networking, tags, and execution-role options for this runner configuration."
# Webhook, queue, and scale-up/scale-down orchestration settings.
orchestration\_provider: {
webhook: {
matcherConfig: "Label matching and dynamic-label policy used to route workflow jobs to this runner configuration."
runner: "Runner lifecycle settings including boot time, ephemeral mode, JIT configuration, and maximum runner count."
queue: "Build queue delay, retention, visibility timeout, redrive, and tags."
}
}
ssm: "SSM parameter paths, tags, and housekeeper settings for runner configuration storage."
observability: "Logging, tracing, and metric settings for the resources in this runner configuration."
# Compute settings for the runner provider.
compute\_provider: {
aws: {
ec2: "AWS EC2 runner settings, including AMI selection, instance types, capacity strategy, VPC and subnet placement, storage, user data, and runner access."
}
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
# 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 = < 0 ? 1 : 0 prefix = var.prefix tags = local.tags kms_key_arn = local.effective_config.ssm.kms_key_id eventbridge = { - enable = local.effective_config.orchestration_provider.webhook.eventbridge.enabled - accept_events = local.effective_config.orchestration_provider.webhook.eventbridge.accept_events + enable = try(local.effective_config.orchestration_provider.webhook.eventbridge.enabled, false) + accept_events = try(local.effective_config.orchestration_provider.webhook.eventbridge.accept_events, []) } runner_matcher_config = local.runner_matcher_config - matcher_config_parameter_store_tier = local.effective_config.orchestration_provider.webhook.matcher_config_parameter_store_tier + matcher_config_parameter_store_tier = try(local.effective_config.orchestration_provider.webhook.matcher_config_parameter_store_tier, "Standard") ssm_paths = { root = local.ssm_root_path @@ -45,13 +46,13 @@ module "webhook" { lambda_s3_bucket = try(local.effective_config.lambda.artifact.s3.bucket, null) webhook_lambda_s3_key = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.s3.key, null) webhook_lambda_s3_object_version = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version, null) - webhook_lambda_apigateway_access_log_settings = local.effective_config.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings + webhook_lambda_apigateway_access_log_settings = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings, null) lambda_runtime = local.effective_config.lambda.runtime lambda_architecture = local.effective_config.lambda.architecture - lambda_zip = local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.zip - lambda_timeout = local.effective_config.orchestration_provider.webhook.lambda.webhook.timeout - lambda_memory_size = local.effective_config.orchestration_provider.webhook.lambda.webhook.memory_size - lambda_tags = local.effective_config.orchestration_provider.webhook.lambda.webhook.tags + lambda_zip = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.zip, null) + lambda_timeout = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.timeout, null) + lambda_memory_size = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.memory_size, null) + lambda_tags = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.tags, {}) tracing_config = local.effective_config.observability.tracing logging_retention_in_days = local.effective_config.observability.logs.retention_in_days logging_kms_key_id = local.effective_config.observability.logs.kms_key_id @@ -59,8 +60,8 @@ module "webhook" { role_path = local.effective_config.roles.path role_permissions_boundary = local.effective_config.roles.permissions_boundary - repository_white_list = local.effective_config.orchestration_provider.webhook.github.repository_white_list - queue_selection_strategy = local.effective_config.orchestration_provider.webhook.queue_selection_strategy + repository_white_list = try(local.effective_config.orchestration_provider.webhook.github.repository_white_list, []) + queue_selection_strategy = try(local.effective_config.orchestration_provider.webhook.queue_selection_strategy, "first") lambda_subnet_ids = local.effective_config.lambda.subnet_ids lambda_security_group_ids = local.effective_config.lambda.security_group_ids diff --git a/modules/orchestration-providers/scale-set/README.md b/modules/orchestration-providers/scale-set/README.md new file mode 100644 index 0000000000..13d0476599 --- /dev/null +++ b/modules/orchestration-providers/scale-set/README.md @@ -0,0 +1,215 @@ +# Scale-set orchestration provider + +This internal module deploys long-running GitHub Actions runner scale-set controllers on ECS Fargate. It creates one deployment unit per resolved **controller group**: + +```text +1 ECS service +1 task definition +1 running task during normal operation +1 application container +1 ScaleSetController supervising N independent reconcilers +``` + +Each reconciler still owns exactly one GitHub scale-set ID and one message session. Grouping only packs reconcilers into a shared task; it does not merge scale-set identity, session state, or compute-provider behavior. It does, however, intentionally union task IAM permissions and failure/deployment blast radius across all members of that controller group. + +This foundation adopts scale sets that were created elsewhere. It validates the supplied ID, expected name, and optional runner-group ID at runtime, but it does not create or delete the GitHub scale-set resource. The complete compute-provider contract must likewise come from its Terraform adapter; until that adapter and the public runner-config selection are wired, this internal module is not an end-to-end deployment interface. + +The normalized `(github.config_url, scale_set.id)` ownership tuple must be globally unique across all groups. Duplicate detection normalizes URL case, one trailing slash, and an explicit default `:443` port, so equivalent spellings cannot accidentally deploy two services against one GitHub message session. Numeric scale-set IDs may repeat under different GitHub scopes. + +## Grouping + +`grouping.strategy` selects a plan-known grouping implementation: + +- `compute_provider` (default): one group per `compute_provider_contracts[*].type`. +- `runner_config`: one group per runner-config key. +- `custom`: explicit groups whose membership covers every runner config exactly once. + +```hcl +grouping = { + strategy = "custom" + custom = { + groups = { + general = { + runner_configs = ["linux-small", "linux-large"] + } + critical = { + runner_configs = ["production"] + } + } + } +} +``` + +Group names and memberships become Terraform `for_each` identities and must be known during planning. A group may contain at most 1000 runner configs, matching the service loader limit. Additional grouping algorithms can be added later by producing the same internal `map(list(runner_config_name))` shape. + +## Compute-provider capability boundary + +`compute_provider_contracts` is keyed exactly like `runner_configs`. A compute provider implements the scale-set desired-capacity interface by returning: + +```hcl +{ + type = "ec2" # plan-known grouping and runtime registry key + capabilities = { + scale_set = { + configuration_json = local.provider_owned_runtime_configuration + environment_variables = local.provider_owned_non_secret_environment + iam_statements = local.provider_owned_task_role_statements + } + } +} +``` + +The symbolic locals above represent outputs from the selected compute-provider Terraform adapter; callers should not recreate the provider payload by hand. The provider-specific adapter owns the runtime configuration schema and the complete IAM statement set. This orchestration module treats configuration JSON as an opaque, non-secret object and combines only the selected group's statements into that group's task role. Provider-owned process environment variables are also non-secret: duplicate names within a group must resolve to the same value, and reserved runtime names cannot be overridden. Runner-config-specific values stay in the SSM reconciler document, while credentials stay behind SSM references. Wildcard IAM actions are rejected. The rendered per-group policy is checked against AWS's 10,240-byte inline role-policy quota with an explicit split-the-group error; group splitting remains the escape hatch when the union is too large or too broad. + +## Configuration delivery + +Large groups do not embed their full manifest in an ECS task definition. The module writes one non-secret SSM `String` parameter per reconciler: + +```text +//scale-set-controller// +``` + +Each leaf is the flat `ScaleSetReconcilerConfig` consumed by the service: + +```json +{ + "schemaVersion": 1, + "runnerConfigName": "linux-small", + "githubConfigUrl": "https://github.com/example", + "scaleSetId": 123, + "expectedScaleSetName": "linux-small", + "expectedRunnerGroupId": null, + "minRunners": 0, + "maxRunners": 20, + "bootTimeoutMinutes": 10, + "githubApp": { + "appIdParameterName": "/github/app-id", + "privateKeyParameterName": "/github/private-key", + "installationIdParameterName": "/github/installation-id" + }, + "computeProvider": { + "type": "ec2", + "configuration": {} + } +} +``` + +The task receives only the group name, group path, and a SHA-256 revision. It loads the direct children with `GetParametersByPath`. Standard parameters are limited to 4096 encoded bytes and Advanced parameters to 8192 encoded bytes; Terraform validates every leaf against the selected tier and limits the decoded aggregate for one group to 4 MiB. The group revision changes the task definition whenever any reconciler configuration changes. + +Terraform always emits `sessionOwner`. An omitted value normally resolves to `.`; if that would exceed the runtime's 256-character limit, the module truncates both readable components and appends a deterministic hash. + +GitHub credential **values** never enter Terraform configuration, task definitions, or controller-config parameters. Each leaf carries only three Parameter Store names. The task role can read the exact credential parameter ARNs for its group and decrypt only explicitly declared KMS keys. + +## Container image + +The convenience default is: + +```text +ghcr.io/github-aws-runners/terraform-aws-github-runner-scale-set-service:latest +``` + +ECS `versionConsistency` is enabled so all tasks in a deployment resolve a tag consistently. Production callers should set `container.image` to the digest published with a release: + +```hcl +container = { + image = "ghcr.io/github-aws-runners/terraform-aws-github-runner-scale-set-service@sha256:" +} +``` + +Public registry images need no pull permission. For a private ECR override, set `container.ecr_repository.arn`; the execution role receives repository-scoped layer permissions plus the unavoidable resource-unscoped `ecr:GetAuthorizationToken` action. + +For the official GHCR default, verify an anonymous pull after the first package publish. Package visibility may inherit repository or organization settings and must not be inferred only from a successful authenticated workflow push. + +## ECS and security behavior + +- A managed ECS cluster is created by default. Set `ecs.cluster.mode = "external"` and pass `ecs.cluster.arn` to reuse a cluster. The mode must be known at plan time; the ARN may be computed. +- Every group gets a separate service, task definition, task role, execution role, log group, and security group. +- `desired_count` is fixed at one. Deployment percentages are `minimum = 0` and `maximum = 100`, preventing old and new tasks from overlapping while session leasing is unavailable. +- The ECS deployment circuit breaker and rollback are enabled. +- Tasks run in supplied private subnets with public IP assignment disabled. Managed security groups have no ingress and allow only TCP/443 egress. The IPv4 Internet default is intended for controlled NAT/firewall paths and can be narrowed. +- The application container runs with a numeric non-root UID/GID, a read-only root filesystem, init enabled, no privilege, and all Linux capabilities dropped. +- ECS probes `/healthz` for liveness, and `container.health_path` accepts only that endpoint. `/readyz` remains an application readiness signal; reconnecting to GitHub should not cause ECS to restart every reconciler in a group. +- CloudWatch encrypts logs at rest with an AWS-owned key by default. Set `logging.kms_key_arn` for a customer-managed key and ensure its key policy allows the regional CloudWatch Logs service. + +## Plan-shape requirements + +The following values control `for_each`, dynamic IAM statements, or resource ownership and must be known during planning: + +- runner-config map keys; +- compute-contract map keys and provider `type`; +- grouping strategy, custom group keys, and membership; +- IAM statement keys and optional KMS/ECR wrapper presence; +- optional ECS ephemeral-storage wrapper presence; +- managed versus external cluster mode. + +Inner values such as scale-set IDs, SSM/KMS ARNs, provider configuration values, IAM actions/resources, and an external cluster ARN may be computed. Nullable computed values should be placed inside a plan-known wrapper rather than used as the wrapper itself. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_log_group.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_ecs_cluster.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster) | resource | +| [aws_ecs_service.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource | +| [aws_ecs_task_definition.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource | +| [aws_iam_role.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_security_group.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_ssm_parameter.reconciler_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [terraform_data.validate_config_store](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_contract](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_group_task_policy](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_grouping](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_runtime](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 | +| [aws_iam_policy_document.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.task_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [compute\_provider\_contracts](#input\_compute\_provider\_contracts) | Provider-neutral, scale-set capability fragments keyed exactly like `runner_configs`.

`type` is the plan-known provider discriminator used by the default grouping implementation and the runtime adapter registry. `configuration_json` is provider-owned, valid JSON and must contain no secrets. `environment_variables` contains non-secret provider process settings shared by every reconciler in the same controller group; conflicting values are rejected. IAM statement map keys and optional condition shapes must be known during planning; their action, resource, and condition values may be computed. |
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//`. The task receives only its group path and a SHA-256 revision, then loads the group with `GetParametersByPath`.

Standard parameters are limited to 4096 encoded bytes and Advanced parameters to 8192 encoded bytes. Null `path_prefix` resolves to `//scale-set-controller`. |
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.

The strategy, custom group keys, and memberships select Terraform `for_each` instances and must be known during planning. |
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 keys must be known during planning. Credential values are never accepted: `github.app` contains only the exact GitHub App Parameter Store references used by the runtime. `github.ssl_verify` applies TLS verification per reconciler without changing process-global TLS behavior. Parameter and optional KMS ARNs, scale-set IDs, and other inner values may remain unknown until apply. |
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 `/`. Values are intentionally not exposed. | +| [resolved\_container\_image](#output\_resolved\_container\_image) | Container image reference selected for the controller task definitions. | + diff --git a/modules/orchestration-providers/scale-set/cluster.tf b/modules/orchestration-providers/scale-set/cluster.tf new file mode 100644 index 0000000000..6c9a586da8 --- /dev/null +++ b/modules/orchestration-providers/scale-set/cluster.tf @@ -0,0 +1,14 @@ +resource "aws_ecs_cluster" "controller" { + count = var.ecs.cluster.mode == "managed" ? 1 : 0 + + name = coalesce(var.ecs.cluster.name, "${var.prefix}-scale-set") + + setting { + name = "containerInsights" + value = var.ecs.cluster.container_insights ? "enabled" : "disabled" + } + + tags = local.common_tags + + depends_on = [terraform_data.validate_runtime] +} diff --git a/modules/orchestration-providers/scale-set/config-store.tf b/modules/orchestration-providers/scale-set/config-store.tf new file mode 100644 index 0000000000..86cb42a73a --- /dev/null +++ b/modules/orchestration-providers/scale-set/config-store.tf @@ -0,0 +1,27 @@ +resource "aws_ssm_parameter" "reconciler_config" { + for_each = local.reconciler_configs + + name = "${local.config_store_path_prefix}/${each.value.group_name}/${each.value.runner_name}" + description = "Non-secret scale-set reconciler configuration for ${each.value.runner_name}" + type = "String" + tier = var.config_store.tier + value = local.reconciler_config_json[each.key] + + tags = merge( + local.group_tags[each.value.group_name], + var.config_store.tags, + ) + + lifecycle { + precondition { + condition = local.reconciler_config_bytes[each.key] <= local.config_store_max_bytes + error_message = "The encoded reconciler configuration exceeds the selected Parameter Store tier limit." + } + } + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_config_store, + ] +} diff --git a/modules/orchestration-providers/scale-set/data.tf b/modules/orchestration-providers/scale-set/data.tf new file mode 100644 index 0000000000..99b50de05a --- /dev/null +++ b/modules/orchestration-providers/scale-set/data.tf @@ -0,0 +1,5 @@ +data "aws_caller_identity" "current" {} + +data "aws_partition" "current" {} + +data "aws_region" "current" {} diff --git a/modules/orchestration-providers/scale-set/iam.tf b/modules/orchestration-providers/scale-set/iam.tf new file mode 100644 index 0000000000..1c23772f00 --- /dev/null +++ b/modules/orchestration-providers/scale-set/iam.tf @@ -0,0 +1,163 @@ +data "aws_iam_policy_document" "task_assume_role" { + statement { + sid = "AllowEcsTasks" + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "aws:SourceAccount" + values = [data.aws_caller_identity.current.account_id] + } + + condition { + test = "ArnLike" + variable = "aws:SourceArn" + values = [format( + "arn:%s:ecs:%s:%s:*", + data.aws_partition.current.partition, + data.aws_region.current.region, + data.aws_caller_identity.current.account_id, + )] + } + } +} + +resource "aws_iam_role" "task" { + for_each = local.controller_groups + + name = "${local.group_resource_names[each.key]}-task" + path = var.ecs.iam.path + permissions_boundary = var.ecs.iam.permissions_boundary + assume_role_policy = data.aws_iam_policy_document.task_assume_role.json + tags = local.group_tags[each.key] + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_runtime, + ] +} + +data "aws_iam_policy_document" "task" { + for_each = local.controller_groups + + source_policy_documents = [local.group_github_kms_policy_json[each.key]] + + statement { + sid = "ReadControllerGroupConfig" + effect = "Allow" + actions = ["ssm:GetParametersByPath"] + resources = [local.group_config_path_arns[each.key]] + } + + statement { + sid = "ReadGitHubAppParameters" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = local.group_ssm_parameter_arns[each.key] + } + + dynamic "statement" { + for_each = local.group_compute_iam_statements[each.key] + + content { + effect = "Allow" + actions = statement.value.actions + resources = statement.value.resources + + dynamic "condition" { + for_each = statement.value.conditions + + content { + test = condition.value.test + variable = condition.value.variable + values = condition.value.values + } + } + } + } +} + +resource "aws_iam_role_policy" "task" { + for_each = local.controller_groups + + name = "scale-set-controller" + role = aws_iam_role.task[each.key].name + policy = data.aws_iam_policy_document.task[each.key].json + + depends_on = [terraform_data.validate_group_task_policy] +} + +resource "aws_iam_role" "execution" { + for_each = local.controller_groups + + name = "${local.group_resource_names[each.key]}-exec" + path = var.ecs.iam.path + permissions_boundary = var.ecs.iam.permissions_boundary + assume_role_policy = data.aws_iam_policy_document.task_assume_role.json + tags = local.group_tags[each.key] + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_runtime, + ] +} + +data "aws_iam_policy_document" "execution" { + for_each = local.controller_groups + + statement { + sid = "WriteControllerLogs" + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = ["${aws_cloudwatch_log_group.controller[each.key].arn}:*"] + } + + dynamic "statement" { + for_each = var.container.ecr_repository == null ? [] : [var.container.ecr_repository] + + content { + sid = "PullPrivateEcrImage" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + ] + resources = [statement.value.arn] + } + } + + dynamic "statement" { + for_each = var.container.ecr_repository == null ? [] : [1] + + content { + # ECR does not support resource-level permissions for authorization tokens. + sid = "AuthorizePrivateEcrPull" + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + } +} + +resource "aws_iam_role_policy" "execution" { + for_each = local.controller_groups + + name = "scale-set-controller-execution" + role = aws_iam_role.execution[each.key].name + policy = data.aws_iam_policy_document.execution[each.key].json +} diff --git a/modules/orchestration-providers/scale-set/locals.tf b/modules/orchestration-providers/scale-set/locals.tf new file mode 100644 index 0000000000..cd74e967a9 --- /dev/null +++ b/modules/orchestration-providers/scale-set/locals.tf @@ -0,0 +1,268 @@ +locals { + configured_runner_names = toset(keys(var.runner_configs)) + contract_runner_names = toset(keys(var.compute_provider_contracts)) + routable_runner_names = sort(tolist(setintersection(local.configured_runner_names, local.contract_runner_names))) + + normalized_github_config_urls = { + for runner_name, runner_config in var.runner_configs : runner_name => replace( + trimsuffix(lower(runner_config.github.config_url), "/"), + ":443/", + "/", + ) + } + github_config_url_ports = { + for runner_name, runner_config in var.runner_configs : runner_name => try( + tonumber(regex("^https://[A-Za-z0-9.-]+:([0-9]+)/", runner_config.github.config_url)[0]), + 443, + ) + } + scale_set_ownership_keys = [ + for runner_name, runner_config in var.runner_configs : + "${local.normalized_github_config_urls[runner_name]}#${runner_config.scale_set.id}" + ] + + declared_custom_groups = var.grouping.strategy == "custom" && var.grouping.custom != null ? { + for group_name, group in var.grouping.custom.groups : group_name => sort(tolist(group.runner_configs)) + } : {} + + custom_members = flatten(values(local.declared_custom_groups)) + + compute_provider_types = distinct([ + for runner_name in local.routable_runner_names : var.compute_provider_contracts[runner_name].type + ]) + + compute_provider_groups = { + for provider_type in local.compute_provider_types : provider_type => [ + for runner_name in local.routable_runner_names : runner_name + if var.compute_provider_contracts[runner_name].type == provider_type + ] + } + + runner_config_groups = { + for runner_name in local.routable_runner_names : runner_name => [runner_name] + } + + custom_groups = { + for group_name, runner_names in local.declared_custom_groups : group_name => [ + for runner_name in runner_names : runner_name + if contains(local.routable_runner_names, runner_name) + ] + } + + controller_groups = ( + var.grouping.strategy == "compute_provider" ? local.compute_provider_groups : + var.grouping.strategy == "runner_config" ? local.runner_config_groups : + var.grouping.strategy == "custom" ? local.custom_groups : + {} + ) + + group_resource_names = { + for group_name in keys(local.controller_groups) : group_name => format( + "%s-ss-%s-%s", + var.prefix, + substr(replace(lower(group_name), "/[^a-z0-9_-]/", "-"), 0, 20), + substr(sha256(group_name), 0, 8), + ) + } + + official_container_image = "ghcr.io/github-aws-runners/terraform-aws-github-runner-scale-set-service:latest" + resolved_container_image = coalesce(var.container.image, local.official_container_image) + + resolved_health_check_command = var.container.health_check_command != null ? var.container.health_check_command : [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:${var.container.health_port}${var.container.health_path}').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + + reconciler_configs = merge([ + for group_name, runner_names in local.controller_groups : { + for runner_name in runner_names : "${group_name}/${runner_name}" => { + group_name = group_name + runner_name = runner_name + value = merge({ + schemaVersion = 1 + runnerConfigName = runner_name + githubConfigUrl = var.runner_configs[runner_name].github.config_url + scaleSetId = var.runner_configs[runner_name].scale_set.id + expectedScaleSetName = var.runner_configs[runner_name].scale_set.name + expectedRunnerGroupId = var.runner_configs[runner_name].scale_set.runner_group_id + minRunners = var.runner_configs[runner_name].scale_set.min_runners + maxRunners = var.runner_configs[runner_name].scale_set.max_runners + bootTimeoutMinutes = var.runner_configs[runner_name].scale_set.boot_time_in_minutes + sslVerify = var.runner_configs[runner_name].github.ssl_verify + sessionOwner = ( + var.runner_configs[runner_name].scale_set.session_owner != null + ? var.runner_configs[runner_name].scale_set.session_owner + : length("${group_name}.${runner_name}") <= 256 + ? "${group_name}.${runner_name}" + : "${substr(group_name, 0, 119)}.${substr(runner_name, 0, 119)}.${substr(sha256(format("%s.%s", group_name, runner_name)), 0, 16)}" + ) + githubApp = { + appIdParameterName = var.runner_configs[runner_name].github.app.app_id.name + privateKeyParameterName = var.runner_configs[runner_name].github.app.private_key.name + installationIdParameterName = var.runner_configs[runner_name].github.app.installation_id.name + } + computeProvider = { + type = var.compute_provider_contracts[runner_name].type + configuration = jsondecode(var.compute_provider_contracts[runner_name].capabilities.scale_set.configuration_json) + } + }, var.runner_configs[runner_name].work_folder == null ? {} : { + workFolder = var.runner_configs[runner_name].work_folder + }, var.runner_configs[runner_name].github.force_ghes == null ? {} : { + forceGhes = var.runner_configs[runner_name].github.force_ghes + }, var.runner_configs[runner_name].github.user_agent == null ? {} : { + userAgent = var.runner_configs[runner_name].github.user_agent + }) + } + } + ]...) + + config_store_path_prefix = coalesce(var.config_store.path_prefix, "/${var.prefix}/scale-set-controller") + group_config_paths = { + for group_name in keys(local.controller_groups) : group_name => "${local.config_store_path_prefix}/${group_name}" + } + group_config_revisions = { + for group_name, runner_names in local.controller_groups : group_name => sha256(jsonencode({ + for runner_name in runner_names : runner_name => local.reconciler_configs["${group_name}/${runner_name}"].value + })) + } + + group_ssm_parameter_arns = { + for group_name, runner_names in local.controller_groups : group_name => flatten([ + for runner_name in runner_names : [ + var.runner_configs[runner_name].github.app.app_id.arn, + var.runner_configs[runner_name].github.app.private_key.arn, + var.runner_configs[runner_name].github.app.installation_id.arn, + ] + ]) + } + + group_ssm_kms_key_arns = { + for group_name, runner_names in local.controller_groups : group_name => flatten([ + for runner_name in runner_names : [ + for parameter in [ + var.runner_configs[runner_name].github.app.app_id, + var.runner_configs[runner_name].github.app.private_key, + var.runner_configs[runner_name].github.app.installation_id, + ] : parameter.kms_key_arn + ] + ]) + } + + group_github_kms_policy_json = { + for group_name, kms_key_arns in local.group_ssm_kms_key_arns : group_name => jsonencode({ + Version = "2012-10-17" + Statement = length(compact(kms_key_arns)) == 0 ? [] : [{ + Sid = "DecryptGitHubAppParameters" + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = distinct(compact(kms_key_arns)) + }] + }) + } + + group_compute_iam_statements = { + for group_name, runner_names in local.controller_groups : group_name => merge([ + for runner_name in runner_names : { + for statement_name, statement in var.compute_provider_contracts[runner_name].capabilities.scale_set.iam_statements : + "${runner_name}/${statement_name}" => statement + } + ]...) + } + + group_compute_environment_entries = { + for group_name, runner_names in local.controller_groups : group_name => flatten([ + for runner_name in runner_names : [ + for name, value in var.compute_provider_contracts[runner_name].capabilities.scale_set.environment_variables : { + runner_name = runner_name + name = name + value = value + } + ] + ]) + } + + group_compute_environment_variables = { + for group_name, entries in local.group_compute_environment_entries : group_name => merge([ + for entry in entries : { (entry.name) = entry.value } + ]...) + } + + reserved_environment_variable_names = toset([ + "PATH", + "HOME", + "HOSTNAME", + "PWD", + "SHLVL", + ]) + + config_store_max_bytes = var.config_store.tier == "Advanced" ? 8192 : 4096 + + reconciler_config_json = { + for config_key, config in local.reconciler_configs : config_key => jsonencode(config.value) + } + reconciler_config_base64 = { + for config_key, config_json in local.reconciler_config_json : config_key => base64encode(config_json) + } + reconciler_config_bytes = { + for config_key, encoded in local.reconciler_config_base64 : config_key => ( + floor(length(encoded) * 3 / 4) - + (endswith(encoded, "==") ? 2 : endswith(encoded, "=") ? 1 : 0) + ) + } + group_reconciler_config_bytes = { + for group_name, runner_names in local.controller_groups : group_name => sum([ + for runner_name in runner_names : local.reconciler_config_bytes["${group_name}/${runner_name}"] + ]) + } + + group_task_policy_base64 = { + for group_name, policy in data.aws_iam_policy_document.task : group_name => base64encode(policy.json) + } + group_task_policy_bytes = { + for group_name, encoded in local.group_task_policy_base64 : group_name => ( + floor(length(encoded) * 3 / 4) - + (endswith(encoded, "==") ? 2 : endswith(encoded, "=") ? 1 : 0) + ) + } + + cluster_arn = var.ecs.cluster.mode == "managed" ? aws_ecs_cluster.controller[0].arn : var.ecs.cluster.arn + + group_config_path_arns = { + for group_name, config_path in local.group_config_paths : group_name => format( + "arn:%s:ssm:%s:%s:parameter%s/*", + data.aws_partition.current.partition, + data.aws_region.current.region, + data.aws_caller_identity.current.account_id, + config_path, + ) + } + + fargate_memory_by_cpu = { + 256 = [512, 1024, 2048] + 512 = [1024, 2048, 3072, 4096] + 1024 = range(2048, 9216, 1024) + 2048 = range(4096, 17408, 1024) + 4096 = range(8192, 31744, 1024) + 8192 = range(16384, 65536, 4096) + 16384 = range(32768, 131072, 8192) + } + + common_tags = merge( + { + "ghr:component" = "scale-set-controller" + }, + var.tags, + ) + + group_tags = { + for group_name, resource_name in local.group_resource_names : group_name => merge( + local.common_tags, + { + Name = resource_name + "ghr:controller-group" = group_name + }, + ) + } +} diff --git a/modules/orchestration-providers/scale-set/logging.tf b/modules/orchestration-providers/scale-set/logging.tf new file mode 100644 index 0000000000..974106316a --- /dev/null +++ b/modules/orchestration-providers/scale-set/logging.tf @@ -0,0 +1,19 @@ +resource "aws_cloudwatch_log_group" "controller" { + for_each = local.controller_groups + + name = "/aws/ecs/${local.group_resource_names[each.key]}" + retention_in_days = var.logging.retention_in_days + kms_key_id = var.logging.kms_key_arn + log_group_class = var.logging.log_group_class + + tags = merge( + local.group_tags[each.key], + var.logging.tags, + ) + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_runtime, + ] +} diff --git a/modules/orchestration-providers/scale-set/networking.tf b/modules/orchestration-providers/scale-set/networking.tf new file mode 100644 index 0000000000..5cd9d11203 --- /dev/null +++ b/modules/orchestration-providers/scale-set/networking.tf @@ -0,0 +1,27 @@ +resource "aws_security_group" "controller" { + for_each = local.controller_groups + + name = local.group_resource_names[each.key] + description = "Private scale-set controller ${each.key}; no ingress and HTTPS-only egress" + vpc_id = var.network.vpc_id + + ingress = [] + + egress { + description = "HTTPS to GitHub and AWS APIs" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = sort(tolist(var.network.https_egress.ipv4_cidrs)) + ipv6_cidr_blocks = sort(tolist(var.network.https_egress.ipv6_cidrs)) + } + + revoke_rules_on_delete = true + tags = local.group_tags[each.key] + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_runtime, + ] +} diff --git a/modules/orchestration-providers/scale-set/outputs.tf b/modules/orchestration-providers/scale-set/outputs.tf new file mode 100644 index 0000000000..fd9d1b8c57 --- /dev/null +++ b/modules/orchestration-providers/scale-set/outputs.tf @@ -0,0 +1,58 @@ +output "cluster" { + description = "Managed or external ECS cluster selected for all controller groups." + value = { + arn = local.cluster_arn + managed = var.ecs.cluster.mode == "managed" + } +} + +output "controller_groups" { + description = "Controller-group resources keyed by stable resolved group name." + value = { + for group_name, runner_names in local.controller_groups : group_name => { + runner_configs = runner_names + config_path = local.group_config_paths[group_name] + config_revision = local.group_config_revisions[group_name] + service = { + id = aws_ecs_service.controller[group_name].id + name = aws_ecs_service.controller[group_name].name + } + task_definition = { + arn = aws_ecs_task_definition.controller[group_name].arn + family = aws_ecs_task_definition.controller[group_name].family + } + task_role = { + arn = aws_iam_role.task[group_name].arn + name = aws_iam_role.task[group_name].name + } + execution_role = { + arn = aws_iam_role.execution[group_name].arn + name = aws_iam_role.execution[group_name].name + } + log_group = { + arn = aws_cloudwatch_log_group.controller[group_name].arn + name = aws_cloudwatch_log_group.controller[group_name].name + } + security_group = { + arn = aws_security_group.controller[group_name].arn + id = aws_security_group.controller[group_name].id + } + } + } +} + +output "reconciler_config_parameters" { + description = "Non-secret SSM controller configuration parameters keyed by `/`. Values are intentionally not exposed." + value = { + for config_key, parameter in aws_ssm_parameter.reconciler_config : config_key => { + arn = parameter.arn + name = parameter.name + tier = parameter.tier + } + } +} + +output "resolved_container_image" { + description = "Container image reference selected for the controller task definitions." + value = local.resolved_container_image +} diff --git a/modules/orchestration-providers/scale-set/service.tf b/modules/orchestration-providers/scale-set/service.tf new file mode 100644 index 0000000000..964849f4e7 --- /dev/null +++ b/modules/orchestration-providers/scale-set/service.tf @@ -0,0 +1,40 @@ +resource "aws_ecs_service" "controller" { + for_each = local.controller_groups + + name = local.group_resource_names[each.key] + cluster = local.cluster_arn + task_definition = aws_ecs_task_definition.controller[each.key].arn + desired_count = 1 + launch_type = "FARGATE" + platform_version = var.ecs.service.platform_version + + scheduling_strategy = "REPLICA" + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + enable_ecs_managed_tags = true + enable_execute_command = false + propagate_tags = "SERVICE" + + deployment_circuit_breaker { + enable = true + rollback = true + } + + deployment_controller { + type = "ECS" + } + + network_configuration { + assign_public_ip = false + security_groups = [aws_security_group.controller[each.key].id] + subnets = sort(tolist(var.network.subnet_ids)) + } + + tags = local.group_tags[each.key] + + depends_on = [ + aws_iam_role_policy.execution, + aws_iam_role_policy.task, + aws_ssm_parameter.reconciler_config, + ] +} diff --git a/modules/orchestration-providers/scale-set/task.tf b/modules/orchestration-providers/scale-set/task.tf new file mode 100644 index 0000000000..a4d0b89392 --- /dev/null +++ b/modules/orchestration-providers/scale-set/task.tf @@ -0,0 +1,112 @@ +resource "aws_ecs_task_definition" "controller" { + for_each = local.controller_groups + + family = local.group_resource_names[each.key] + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.ecs.task.cpu) + memory = tostring(var.ecs.task.memory) + task_role_arn = aws_iam_role.task[each.key].arn + execution_role_arn = aws_iam_role.execution[each.key].arn + + runtime_platform { + cpu_architecture = var.ecs.task.cpu_architecture + operating_system_family = "LINUX" + } + + dynamic "ephemeral_storage" { + for_each = var.ecs.task.ephemeral_storage == null ? [] : [var.ecs.task.ephemeral_storage] + + content { + size_in_gib = ephemeral_storage.value.size_in_gib + } + } + + container_definitions = jsonencode([ + { + name = "scale-set-controller" + image = local.resolved_container_image + essential = true + user = var.container.user + privileged = false + readonlyRootFilesystem = true + stopTimeout = var.container.stop_timeout_seconds + versionConsistency = "enabled" + linuxParameters = { + initProcessEnabled = true + capabilities = { + drop = ["ALL"] + } + } + environment = concat( + [ + { + name = "SCALE_SET_CONTROLLER_GROUP_NAME" + value = each.key + }, + { + name = "SCALE_SET_CONTROLLER_GROUP_CONFIG_PATH" + value = local.group_config_paths[each.key] + }, + { + name = "SCALE_SET_CONTROLLER_GROUP_CONFIG_REVISION" + value = local.group_config_revisions[each.key] + }, + { + name = "SCALE_SET_HEALTH_PORT" + value = tostring(var.container.health_port) + }, + { + name = "SCALE_SET_HEALTH_STALE_AFTER_SECONDS" + value = tostring(var.container.health_stale_after_seconds) + }, + { + name = "SCALE_SET_SHUTDOWN_TIMEOUT_SECONDS" + value = tostring(var.container.shutdown_timeout_seconds) + }, + { + name = "SCALE_SET_SESSION_CLOSE_TIMEOUT_SECONDS" + value = tostring(var.container.session_close_timeout_seconds) + }, + { + name = "SCALE_SET_RECONNECT_INITIAL_BACKOFF_SECONDS" + value = tostring(var.container.reconnect_initial_backoff_seconds) + }, + { + name = "SCALE_SET_RECONNECT_MAX_BACKOFF_SECONDS" + value = tostring(var.container.reconnect_max_backoff_seconds) + }, + ], + [ + for name in sort(keys(local.group_compute_environment_variables[each.key])) : { + name = name + value = local.group_compute_environment_variables[each.key][name] + } + ], + ) + healthCheck = { + command = local.resolved_health_check_command + interval = var.container.health_check_interval + timeout = var.container.health_check_timeout + retries = var.container.health_check_retries + startPeriod = var.container.health_check_start_period + } + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.controller[each.key].name + "awslogs-region" = data.aws_region.current.region + "awslogs-stream-prefix" = "controller" + } + } + } + ]) + + tags = local.group_tags[each.key] + + depends_on = [ + aws_iam_role_policy.execution, + aws_iam_role_policy.task, + aws_ssm_parameter.reconciler_config, + ] +} diff --git a/modules/orchestration-providers/scale-set/tests/computed-inputs.tftest.hcl b/modules/orchestration-providers/scale-set/tests/computed-inputs.tftest.hcl new file mode 100644 index 0000000000..22640ccd7f --- /dev/null +++ b/modules/orchestration-providers/scale-set/tests/computed-inputs.tftest.hcl @@ -0,0 +1,49 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + } + } + + mock_data "aws_region" { + defaults = { + region = "eu-west-1" + } + } + + 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/computed-test" + } + } +} + +run "plans_with_computed_values_inside_known_wrappers" { + command = plan + + module { + source = "./tests/fixtures/computed-inputs" + } + + assert { + condition = ( + toset(keys(output.controller_groups)) == toset(["ec2"]) && + toset(output.controller_groups.ec2.runner_configs) == toset(["computed"]) && + !output.cluster.managed && + toset(keys(output.reconciler_config_parameters)) == toset(["ec2/computed"]) + ) + error_message = "Computed inner values and explicit nulls must not affect group, ownership, IAM-wrapper, or cluster resource shape." + } +} diff --git a/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/README.md b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/README.md new file mode 100644 index 0000000000..8ee616b61b --- /dev/null +++ b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/README.md @@ -0,0 +1,38 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +|------|---------| +| [terraform](#provider\_terraform) | n/a | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [subject](#module\_subject) | ../../.. | n/a | + +## Resources + +| Name | Type | +|------|------| +| [terraform_data.computed](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | + +## Inputs + +No inputs. + +## Outputs + +| Name | Description | +|------|-------------| +| [cluster](#output\_cluster) | n/a | +| [controller\_groups](#output\_controller\_groups) | n/a | +| [reconciler\_config\_parameters](#output\_reconciler\_config\_parameters) | n/a | + \ No newline at end of file diff --git a/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/main.tf b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/main.tf new file mode 100644 index 0000000000..1bbb74f924 --- /dev/null +++ b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/main.tf @@ -0,0 +1,104 @@ +resource "terraform_data" "computed" { + input = { + external_cluster_arn = "arn:aws:ecs:eu-west-1:123456789012:cluster/external" + github_config_url = "https://github.com/example" + scale_set_id = 901 + app_id_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/computed/app-id" + private_key_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/computed/private-key" + installation_id_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/computed/installation-id" + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/33333333-3333-3333-3333-333333333333" + launch_template_name = "lt-computed" + action = "ec2:RunInstances" + resource = "arn:aws:ec2:eu-west-1:123456789012:launch-template/lt-computed" + } +} + +module "subject" { + source = "../../.." + + prefix = "computed-test" + + runner_configs = { + computed = { + github = { + config_url = terraform_data.computed.output.github_config_url + app = { + app_id = { + name = "/github/computed/app-id" + arn = terraform_data.computed.output.app_id_arn + } + private_key = { + name = "/github/computed/private-key" + arn = terraform_data.computed.output.private_key_arn + kms_key_arn = terraform_data.computed.output.kms_key_arn + } + installation_id = { + name = "/github/computed/installation-id" + arn = terraform_data.computed.output.installation_id_arn + } + } + } + scale_set = { + id = terraform_data.computed.output.scale_set_id + name = "computed" + runner_group_id = null + } + } + } + + compute_provider_contracts = { + computed = { + type = "ec2" + capabilities = { + scale_set = { + configuration_json = jsonencode({ + region = "eu-west-1" + environment = "computed-test" + runnerOwner = "example" + runnerType = "Org" + runnerNamePrefix = "computed-" + jitConfigParameterPath = "/computed-test/runners/tokens" + subnets = ["subnet-12345678"] + launchTemplateName = terraform_data.computed.output.launch_template_name + ec2instanceCriteria = { + instanceTypes = ["m7i.large"] + targetCapacityType = "on-demand" + instanceAllocationStrategy = "lowest-price" + } + scaleErrors = [] + }) + iam_statements = { + run_instances = { + actions = [terraform_data.computed.output.action] + resources = [terraform_data.computed.output.resource] + } + } + } + } + } + } + + ecs = { + cluster = { + mode = "external" + arn = terraform_data.computed.output.external_cluster_arn + } + } + + network = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + } +} + +output "controller_groups" { + value = module.subject.controller_groups +} + +output "cluster" { + value = module.subject.cluster +} + +output "reconciler_config_parameters" { + value = module.subject.reconciler_config_parameters +} diff --git a/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/versions.tf b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/versions.tf new file mode 100644 index 0000000000..3ef011ea0a --- /dev/null +++ b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/orchestration-providers/scale-set/tests/scale-set.tftest.hcl b/modules/orchestration-providers/scale-set/tests/scale-set.tftest.hcl new file mode 100644 index 0000000000..f90142015f --- /dev/null +++ b/modules/orchestration-providers/scale-set/tests/scale-set.tftest.hcl @@ -0,0 +1,981 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + } + } + + mock_data "aws_region" { + defaults = { + region = "eu-west-1" + } + } + + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/scale-set-test" + } + } + + mock_resource "aws_ecs_cluster" { + defaults = { + arn = "arn:aws:ecs:eu-west-1:123456789012:cluster/scale-set-test" + } + } +} + +variables { + prefix = "scale-set-test" + + runner_configs = { + linux-small = { + github = { + config_url = "https://github.com/example" + app = { + app_id = { + name = "/github/linux-small/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-small/app-id" + } + private_key = { + name = "/github/linux-small/private-key" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-small/private-key" + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + } + installation_id = { + name = "/github/linux-small/installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-small/installation-id" + } + } + user_agent = "scale-set-test" + ssl_verify = false + } + scale_set = { + id = 101 + name = "linux-small" + runner_group_id = 1 + min_runners = 1 + max_runners = 10 + } + } + linux-large = { + github = { + config_url = "https://github.com/example" + app = { + app_id = { + name = "/github/linux-large/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-large/app-id" + } + private_key = { + name = "/github/linux-large/private-key" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-large/private-key" + } + installation_id = { + name = "/github/linux-large/installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-large/installation-id" + } + } + } + scale_set = { + id = 102 + name = "linux-large" + min_runners = 0 + max_runners = 20 + } + work_folder = "_work/linux-large" + } + microvm = { + github = { + config_url = "https://github.com/example/repository" + app = { + app_id = { + name = "/github/microvm/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/app-id" + } + private_key = { + name = "/github/microvm/private-key" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/private-key" + } + installation_id = { + name = "/github/microvm/installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/installation-id" + } + } + force_ghes = false + } + scale_set = { + id = 201 + name = "microvm" + min_runners = 0 + max_runners = 5 + session_owner = "test.microvm" + } + } + } + + compute_provider_contracts = { + linux-small = { + type = "ec2" + capabilities = { + scale_set = { + configuration_json = jsonencode({ + region = "eu-west-1" + environment = "scale-set-test" + runnerOwner = "example" + runnerType = "Org" + runnerNamePrefix = "small-" + jitConfigParameterPath = "/scale-set-test/runners/tokens" + subnets = ["subnet-11111111"] + launchTemplateName = "lt-small" + ec2instanceCriteria = { + instanceTypes = ["m7i.large"] + targetCapacityType = "on-demand" + instanceAllocationStrategy = "lowest-price" + } + scaleErrors = [] + }) + environment_variables = { + EC2_CONTROLLER_MODE = "grouped" + } + iam_statements = { + run_instances = { + actions = ["ec2:RunInstances"] + resources = ["arn:aws:ec2:eu-west-1:123456789012:launch-template/lt-small"] + } + } + } + } + } + linux-large = { + type = "ec2" + capabilities = { + scale_set = { + configuration_json = jsonencode({ + region = "eu-west-1" + environment = "scale-set-test" + runnerOwner = "example" + runnerType = "Org" + runnerNamePrefix = "large-" + jitConfigParameterPath = "/scale-set-test/runners/tokens" + subnets = ["subnet-22222222"] + launchTemplateName = "lt-large" + ec2instanceCriteria = { + instanceTypes = ["m7i.xlarge"] + targetCapacityType = "on-demand" + instanceAllocationStrategy = "lowest-price" + } + scaleErrors = [] + }) + environment_variables = { + EC2_CONTROLLER_MODE = "grouped" + } + iam_statements = { + run_instances = { + actions = ["ec2:RunInstances"] + resources = ["arn:aws:ec2:eu-west-1:123456789012:launch-template/lt-large"] + } + } + } + } + } + microvm = { + # Future provider used only to prove grouping remains provider-neutral. + type = "microvm" + capabilities = { + scale_set = { + configuration_json = jsonencode({ image_arn = "arn:aws:lambda:eu-west-1:123456789012:runtime-management-config:microvm" }) + iam_statements = { + run_microvm = { + actions = ["lambda:InvokeFunction"] + resources = ["arn:aws:lambda:eu-west-1:123456789012:function:microvm"] + } + } + } + } + } + } + + network = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-11111111", "subnet-22222222"] + } + + logging = { + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/22222222-2222-2222-2222-222222222222" + } + + tags = { + Test = "scale-set" + } +} + +run "groups_by_compute_provider_and_hardens_each_task" { + command = plan + + assert { + condition = ( + toset(keys(output.controller_groups)) == toset(["ec2", "microvm"]) && + toset(output.controller_groups["ec2"].runner_configs) == toset(["linux-small", "linux-large"]) && + toset(output.controller_groups["microvm"].runner_configs) == toset(["microvm"]) + ) + error_message = "The default strategy must create one controller group per compute-provider type." + } + + assert { + condition = ( + length(aws_ecs_service.controller) == 2 && + length(aws_ecs_task_definition.controller) == 2 && + length(aws_iam_role.task) == 2 && + length(aws_cloudwatch_log_group.controller) == 2 && + length(aws_security_group.controller) == 2 && + length(aws_ssm_parameter.reconciler_config) == 3 + ) + error_message = "Every group must own one service, task definition, task role, log group, and security group while every reconciler gets one config parameter." + } + + assert { + condition = alltrue([ + for service in values(aws_ecs_service.controller) : ( + service.desired_count == 1 && + service.deployment_minimum_healthy_percent == 0 && + service.deployment_maximum_percent == 100 && + service.deployment_circuit_breaker[0].enable && + service.deployment_circuit_breaker[0].rollback && + !service.network_configuration[0].assign_public_ip && + length(service.network_configuration[0].security_groups) == 1 + ) + ]) + error_message = "Services must run one private task and use stop-first deployment with circuit-breaker rollback." + } + + assert { + condition = alltrue([ + for task in values(aws_ecs_task_definition.controller) : ( + length(jsondecode(task.container_definitions)) == 1 && + jsondecode(task.container_definitions)[0].image == "ghcr.io/github-aws-runners/terraform-aws-github-runner-scale-set-service:latest" && + jsondecode(task.container_definitions)[0].versionConsistency == "enabled" && + jsondecode(task.container_definitions)[0].readonlyRootFilesystem && + !jsondecode(task.container_definitions)[0].privileged && + jsondecode(task.container_definitions)[0].user == "10001:10001" && + jsondecode(task.container_definitions)[0].linuxParameters.capabilities.drop == ["ALL"] && + jsondecode(task.container_definitions)[0].healthCheck.command[3] == "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" && + !contains([for entry in jsondecode(task.container_definitions)[0].environment : entry.name], "SCALE_SET_CONTROLLER_MANIFEST") + ) + ]) + error_message = "Each task definition must contain one hardened controller container using group-path configuration and /healthz liveness." + } + + assert { + condition = one([ + for entry in jsondecode(aws_ecs_task_definition.controller["ec2"].container_definitions)[0].environment : + entry.value if entry.name == "EC2_CONTROLLER_MODE" + ]) == "grouped" + error_message = "Provider-owned non-secret environment variables must be merged into their controller group task." + } + + assert { + condition = ( + length(aws_security_group.controller["ec2"].ingress) == 0 && + length(aws_security_group.controller["ec2"].egress) == 1 && + one(aws_security_group.controller["ec2"].egress).from_port == 443 && + one(aws_security_group.controller["ec2"].egress).to_port == 443 && + aws_cloudwatch_log_group.controller["ec2"].kms_key_id == var.logging.kms_key_arn + ) + error_message = "Controller networking must have no ingress and only HTTPS egress, and logs must honor customer-managed encryption." + } + + assert { + condition = ( + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).schemaVersion == 1 && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).runnerConfigName == "linux-small" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).scaleSetId == 101 && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).expectedScaleSetName == "linux-small" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).bootTimeoutMinutes == 10 && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).sslVerify == false && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).githubApp.privateKeyParameterName == "/github/linux-small/private-key" && + !contains(keys(jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value))), "runnerConfig") + ) + error_message = "Each SSM leaf must use the frozen flat reconciler schema and contain references instead of GitHub credential values." + } + + assert { + condition = ( + contains(flatten([for statement in data.aws_iam_policy_document.task["ec2"].statement : statement.resources]), "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-small/private-key") && + !contains(flatten([for statement in data.aws_iam_policy_document.task["ec2"].statement : statement.resources]), "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/private-key") && + contains(jsondecode(local.group_github_kms_policy_json["ec2"]).Statement[0].Resource, "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111") && + length(jsondecode(local.group_github_kms_policy_json["microvm"]).Statement) == 0 && + contains(flatten([for statement in data.aws_iam_policy_document.task["ec2"].statement : statement.resources]), "arn:aws:ssm:eu-west-1:123456789012:parameter/scale-set-test/scale-set-controller/ec2/*") + ) + error_message = "Task IAM must be scoped to its group config prefix, credential parameters, KMS keys, and compute resources." + } +} + +run "supports_one_group_per_runner_config" { + command = plan + + variables { + grouping = { + strategy = "runner_config" + } + container = { + image = "ghcr.io/example/scale-set-controller@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + + assert { + condition = ( + toset(keys(output.controller_groups)) == toset(["linux-small", "linux-large", "microvm"]) && + length(aws_ecs_service.controller) == 3 && + output.resolved_container_image == "ghcr.io/example/scale-set-controller@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + error_message = "runner_config grouping must create one independently deployable controller task per runner config and honor an image override." + } +} + +run "supports_exact_custom_groups" { + command = plan + + variables { + grouping = { + strategy = "custom" + custom = { + groups = { + general = { + runner_configs = ["linux-small", "microvm"] + } + isolated = { + runner_configs = ["linux-large"] + } + } + } + } + } + + assert { + condition = ( + toset(keys(output.controller_groups)) == toset(["general", "isolated"]) && + toset(output.controller_groups.general.runner_configs) == toset(["linux-small", "microvm"]) && + toset(output.controller_groups.isolated.runner_configs) == toset(["linux-large"]) + ) + error_message = "Custom grouping must preserve the exact declared assignment." + } +} + +run "rejects_duplicate_custom_membership" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + grouping = { + strategy = "custom" + custom = { + groups = { + first = { + runner_configs = ["linux-small", "linux-large"] + } + second = { + runner_configs = ["linux-small", "microvm"] + } + } + } + } + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_incomplete_custom_membership" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + grouping = { + strategy = "custom" + custom = { + groups = { + partial = { + runner_configs = ["linux-small", "linux-large"] + } + } + } + } + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_contract_key_mismatch" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + compute_provider_contracts = { + linux-small = var.compute_provider_contracts.linux-small + linux-large = var.compute_provider_contracts.linux-large + } + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_readiness_path_as_ecs_liveness" { + command = plan + + plan_options { + target = [terraform_data.validate_runtime] + } + + variables { + container = { + health_path = "/readyz" + } + } + + expect_failures = [terraform_data.validate_runtime] +} + +run "rejects_oversized_standard_parameter" { + command = plan + + plan_options { + target = [terraform_data.validate_config_store] + } + + variables { + compute_provider_contracts = merge(var.compute_provider_contracts, { + linux-small = merge(var.compute_provider_contracts.linux-small, { + capabilities = { + scale_set = merge(var.compute_provider_contracts.linux-small.capabilities.scale_set, { + configuration_json = jsonencode({ payload = join("", [for index in range(1000) : "xxxxxx"]) }) + }) + } + }) + }) + } + + expect_failures = [terraform_data.validate_config_store] +} + +run "accepts_advanced_parameter_within_eight_kib" { + command = plan + + plan_options { + target = [terraform_data.validate_config_store] + } + + variables { + config_store = { + tier = "Advanced" + } + compute_provider_contracts = merge(var.compute_provider_contracts, { + linux-small = merge(var.compute_provider_contracts.linux-small, { + capabilities = { + scale_set = merge(var.compute_provider_contracts.linux-small.capabilities.scale_set, { + configuration_json = jsonencode({ payload = join("", [for index in range(800) : "xxxxxx"]) }) + }) + } + }) + }) + } + + assert { + condition = local.reconciler_config_bytes["ec2/linux-small"] > 4096 && local.reconciler_config_bytes["ec2/linux-small"] <= 8192 + error_message = "Advanced Parameter Store tier must accept reconciler JSON between four and eight KiB." + } +} + +run "rejects_duplicate_scale_set_ownership_across_groups" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + config_url = "https://GITHUB.COM:443/example/" + }) + scale_set = merge(var.runner_configs.microvm.scale_set, { + id = 101 + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_leading_zero_default_port_spelling" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + config_url = "https://github.com:0443/example/" + }) + scale_set = merge(var.runner_configs.microvm.scale_set, { + id = 101 + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_port_above_url_maximum" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + config_url = "https://github.com:65536/example" + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_non_ascii_scale_set_name" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + scale_set = merge(var.runner_configs.microvm.scale_set, { + name = "microvm-☃" + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_invalid_compute_provider_type_identifier" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + compute_provider_contracts = merge(var.compute_provider_contracts, { + microvm = merge(var.compute_provider_contracts.microvm, { + type = "AWS.MicroVM" + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_scale_set_id_above_runtime_integer_maximum" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + scale_set = merge(var.runner_configs.microvm.scale_set, { + id = 2147483648 + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_runner_group_id_above_runtime_integer_maximum" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + scale_set = merge(var.runner_configs.microvm.scale_set, { + runner_group_id = 2147483648 + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_credential_arn_name_mismatch" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + app = merge(var.runner_configs.microvm.github.app, { + app_id = merge(var.runner_configs.microvm.github.app.app_id, { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/another/app-id" + }) + }) + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_cross_account_credential_parameter" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + app = merge(var.runner_configs.microvm.github.app, { + app_id = merge(var.runner_configs.microvm.github.app.app_id, { + arn = "arn:aws:ssm:eu-west-1:210987654321:parameter/github/microvm/app-id" + }) + }) + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "allows_same_numeric_scale_set_id_in_another_github_scope" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + scale_set = merge(var.runner_configs.microvm.scale_set, { + id = 101 + }) + }) + }) + } + + assert { + condition = length(local.scale_set_ownership_keys) == length(distinct(local.scale_set_ownership_keys)) + error_message = "Scale-set IDs are scoped to their normalized GitHub configuration URL." + } +} + +run "bounds_default_session_owner_for_maximum_names" { + command = plan + + variables { + grouping = { + strategy = "runner_config" + } + runner_configs = { + (join("", [for index in range(128) : "a"])) = { + github = { + config_url = "https://github.com/example" + app = { + app_id = { + name = "/github/max/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/max/app-id" + } + private_key = { + name = "/github/max/private-key" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/max/private-key" + } + installation_id = { + name = "/github/max/installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/max/installation-id" + } + } + } + scale_set = { + id = 301 + name = "maximum-name" + } + } + } + compute_provider_contracts = { + (join("", [for index in range(128) : "a"])) = { + type = "ec2" + capabilities = { + scale_set = { + configuration_json = "{}" + } + } + } + } + } + + assert { + condition = ( + length(one(values(local.reconciler_configs)).value.sessionOwner) == 256 && + can(regex("^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$", one(values(local.reconciler_configs)).value.sessionOwner)) + ) + error_message = "A generated session owner must remain deterministic and within the runtime's 256-character limit." + } +} + +run "rejects_controller_group_policy_above_inline_quota" { + command = plan + + plan_options { + target = [terraform_data.validate_group_task_policy["ec2"]] + } + + override_data { + target = data.aws_iam_policy_document.task["ec2"] + values = { + json = <<-JSON + {"payload":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} + JSON + } + } + + expect_failures = [terraform_data.validate_group_task_policy["ec2"]] +} + +run "rejects_conflicting_group_environment_variables" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + compute_provider_contracts = merge(var.compute_provider_contracts, { + linux-large = merge(var.compute_provider_contracts.linux-large, { + capabilities = { + scale_set = merge(var.compute_provider_contracts.linux-large.capabilities.scale_set, { + environment_variables = { + EC2_CONTROLLER_MODE = "isolated" + } + }) + } + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_controller_group_environment_above_task_definition_budget" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + compute_provider_contracts = merge(var.compute_provider_contracts, { + linux-small = merge(var.compute_provider_contracts.linux-small, { + capabilities = { + scale_set = merge(var.compute_provider_contracts.linux-small.capabilities.scale_set, { + environment_variables = merge( + var.compute_provider_contracts.linux-small.capabilities.scale_set.environment_variables, + { + for index in range(16) : format("EC2_QUOTA_%02d", index) => join("", [for part in range(1024) : "xxxx"]) + }, + ) + }) + } + }) + }) + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_reserved_provider_environment_variables" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + compute_provider_contracts = merge(var.compute_provider_contracts, { + linux-small = merge(var.compute_provider_contracts.linux-small, { + capabilities = { + scale_set = merge(var.compute_provider_contracts.linux-small.capabilities.scale_set, { + environment_variables = { + SCALE_SET_OVERRIDE = "unsafe" + } + }) + } + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_invalid_boot_timeout" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + scale_set = merge(var.runner_configs.linux-small.scale_set, { + boot_time_in_minutes = 0 + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_controller_group_above_runtime_reconciler_limit" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + runner_configs = { + for index in range(1001) : format("runner-%04d", index) => var.runner_configs.linux-small + } + compute_provider_contracts = { + for index in range(1001) : format("runner-%04d", index) => var.compute_provider_contracts.linux-small + } + grouping = { + strategy = "custom" + custom = { + groups = { + oversized = { + runner_configs = toset([for index in range(1001) : format("runner-%04d", index)]) + } + } + } + } + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_controller_group_above_runtime_config_bytes" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + config_store = { + tier = "Advanced" + } + runner_configs = { + for index in range(900) : format("runner-%04d", index) => var.runner_configs.linux-small + } + compute_provider_contracts = { + for index in range(900) : format("runner-%04d", index) => merge(var.compute_provider_contracts.linux-small, { + capabilities = { + scale_set = merge(var.compute_provider_contracts.linux-small.capabilities.scale_set, { + configuration_json = jsonencode({ + payload = join("", [for part in range(1000) : "xxxxx"]) + }) + }) + } + }) + } + grouping = { + strategy = "custom" + custom = { + groups = { + oversized = { + runner_configs = toset([for index in range(900) : format("runner-%04d", index)]) + } + } + } + } + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_runtime_invalid_credential_parameter_name" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + app = merge(var.runner_configs.microvm.github.app, { + app_id = { + name = "/github/microvm/bad app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/bad app-id" + } + }) + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} diff --git a/modules/orchestration-providers/scale-set/validations.tf b/modules/orchestration-providers/scale-set/validations.tf new file mode 100644 index 0000000000..81a34368d3 --- /dev/null +++ b/modules/orchestration-providers/scale-set/validations.tf @@ -0,0 +1,387 @@ +locals { + group_compute_environment_base64 = { + for group_name, environment_variables in local.group_compute_environment_variables : group_name => base64encode(jsonencode([ + for name in sort(keys(environment_variables)) : { + name = name + value = environment_variables[name] + } + ])) + } + group_compute_environment_bytes = { + for group_name, encoded in local.group_compute_environment_base64 : group_name => ( + floor(length(encoded) * 3 / 4) - + (endswith(encoded, "==") ? 2 : endswith(encoded, "=") ? 1 : 0) + ) + } +} + +resource "terraform_data" "validate_contract" { + lifecycle { + precondition { + condition = ( + length(var.prefix) >= 1 && + length(var.prefix) <= 20 && + can(regex("^[a-z0-9][a-z0-9-]*$", var.prefix)) + ) + error_message = "prefix must contain 1 to 20 lowercase ASCII letters, digits, or hyphens and start with a letter or digit." + } + + precondition { + condition = ( + length(setsubtract(local.configured_runner_names, local.contract_runner_names)) == 0 && + length(setsubtract(local.contract_runner_names, local.configured_runner_names)) == 0 + ) + error_message = "runner_configs and compute_provider_contracts must have exactly the same keys." + } + + precondition { + condition = alltrue([ + for runner_name in keys(var.runner_configs) : ( + length(runner_name) >= 1 && + length(runner_name) <= 128 && + can(regex("^[A-Za-z0-9][A-Za-z0-9._-]*$", runner_name)) + ) + ]) + error_message = "runner-config keys must contain 1 to 128 ASCII letters, digits, dots, underscores, or hyphens and start with a letter or digit." + } + + precondition { + condition = alltrue([ + for runner_name, runner_config in var.runner_configs : ( + can(regex("^https://[A-Za-z0-9.-]+(:[1-9][0-9]{0,4})?/[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?/?$", runner_config.github.config_url)) && + local.github_config_url_ports[runner_name] <= 65535 + ) + ]) + error_message = "Each github.config_url must be an HTTPS GitHub organization, repository, or enterprise URL without credentials, query, fragment, or whitespace." + } + + precondition { + condition = length(local.scale_set_ownership_keys) == length(distinct(local.scale_set_ownership_keys)) + error_message = "Each normalized github.config_url and scale_set.id tuple must be unique across runner_configs so two controller services cannot own the same message session. URL matching ignores case, one trailing slash, and the default HTTPS port." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.runner_configs) : ( + alltrue([ + for parameter in [ + runner_config.github.app.app_id, + runner_config.github.app.private_key, + runner_config.github.app.installation_id, + ] : ( + length(parameter.name) <= 2048 && + can(regex("^/[A-Za-z0-9_./-]+$", parameter.name)) && + !endswith(parameter.name, "/") && + !can(regex("//", parameter.name)) && + parameter.arn == format( + "arn:%s:ssm:%s:%s:parameter%s", + data.aws_partition.current.partition, + data.aws_region.current.region, + data.aws_caller_identity.current.account_id, + parameter.name, + ) && + (parameter.kms_key_arn == null ? true : can(regex("^arn:[^:]+:kms:[^:]+:[0-9]{12}:key/.+$", parameter.kms_key_arn))) + ) + ]) + ) + ]) + error_message = "GitHub App credentials must use valid absolute SSM parameter names and exact same-account, same-region parameter ARNs; optional KMS references must be key ARNs." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.runner_configs) : ( + can(regex("^[ -~]{1,128}$", runner_config.scale_set.name)) && + runner_config.scale_set.id >= 1 && + runner_config.scale_set.id <= 2147483647 && + floor(runner_config.scale_set.id) == runner_config.scale_set.id && + (runner_config.scale_set.runner_group_id == null ? true : ( + runner_config.scale_set.runner_group_id >= 1 && + runner_config.scale_set.runner_group_id <= 2147483647 && + floor(runner_config.scale_set.runner_group_id) == runner_config.scale_set.runner_group_id + )) && + runner_config.scale_set.min_runners >= 0 && + floor(runner_config.scale_set.min_runners) == runner_config.scale_set.min_runners && + runner_config.scale_set.max_runners >= 1 && + runner_config.scale_set.max_runners <= 10000 && + floor(runner_config.scale_set.max_runners) == runner_config.scale_set.max_runners && + runner_config.scale_set.min_runners <= runner_config.scale_set.max_runners && + runner_config.scale_set.boot_time_in_minutes >= 1 && + runner_config.scale_set.boot_time_in_minutes <= 120 && + floor(runner_config.scale_set.boot_time_in_minutes) == runner_config.scale_set.boot_time_in_minutes && + (runner_config.scale_set.session_owner == null ? true : can(regex("^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$", runner_config.scale_set.session_owner))) && + (runner_config.work_folder == null ? true : ( + length(runner_config.work_folder) <= 128 && + !startswith(runner_config.work_folder, "/") && + !can(regex("\\\\", runner_config.work_folder)) && + can(regex("^[A-Za-z0-9._/-]+$", runner_config.work_folder)) && + alltrue([for part in split("/", runner_config.work_folder) : !contains(["", ".", ".."], part)]) + )) && + (runner_config.github.user_agent == null ? true : ( + length(runner_config.github.user_agent) <= 256 && + can(regex("^[ -~]+$", runner_config.github.user_agent)) + )) + ) + ]) + error_message = "Scale-set names and IDs must be valid, boot_time_in_minutes must be an integer from 1 through 120, optional session/work-folder/user-agent values must match runtime constraints, and min_runners must be between zero and max_runners (maximum 10000)." + } + + precondition { + condition = alltrue([ + for contract in values(var.compute_provider_contracts) : ( + can(regex("^[a-z][a-z0-9_-]{0,63}$", contract.type)) && + can(keys(jsondecode(contract.capabilities.scale_set.configuration_json))) && + length(contract.capabilities.scale_set.environment_variables) <= 64 && + alltrue([ + for name, value in contract.capabilities.scale_set.environment_variables : ( + can(regex("^[A-Z][A-Z0-9_]{0,127}$", name)) && + !contains(local.reserved_environment_variable_names, name) && + alltrue([ + for prefix in ["AWS_", "ECS_", "GITHUB_", "SCALE_SET_", "NODE_"] : + !startswith(name, prefix) + ]) && + length(regexall("[\\x00-\\x1F\\x7F]", value)) == 0 && + ( + floor(length(base64encode(value)) * 3 / 4) - + (endswith(base64encode(value), "==") ? 2 : endswith(base64encode(value), "=") ? 1 : 0) + ) <= 4096 + ) + ]) && + alltrue([ + for statement_name, statement in contract.capabilities.scale_set.iam_statements : ( + can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", statement_name)) && + length(statement.actions) > 0 && + length(statement.resources) > 0 && + alltrue([for action in statement.actions : !can(regex("\\*", action))]) && + alltrue([ + for condition in statement.conditions : ( + length(condition.test) > 0 && + length(condition.variable) > 0 && + length(condition.values) > 0 + ) + ]) + ) + ]) + ) + ]) + error_message = "Each compute-provider scale-set capability must have safe identifiers, object-shaped configuration JSON, non-secret environment variables with safe unreserved names and bounded values, and non-empty least-privilege IAM statements without wildcard actions." + } + + precondition { + condition = alltrue([ + for group_name, entries in local.group_compute_environment_entries : alltrue([ + for name in distinct([for entry in entries : entry.name]) : + length(distinct([for entry in entries : entry.value if entry.name == name])) <= 1 + ]) + ]) + error_message = "Compute-provider environment variables grouped into the same controller task must use identical values for duplicate names. Use a different grouping strategy when providers require conflicting process settings." + } + } +} + +resource "terraform_data" "validate_grouping" { + lifecycle { + precondition { + condition = contains(["compute_provider", "runner_config", "custom"], var.grouping.strategy) + error_message = "grouping.strategy must be compute_provider, runner_config, or custom." + } + + precondition { + condition = ( + var.grouping.strategy == "custom" + ? var.grouping.custom != null && length(var.grouping.custom.groups) > 0 + : var.grouping.custom == null + ) + error_message = "grouping.custom must be non-null and non-empty only when grouping.strategy is custom." + } + + precondition { + condition = var.grouping.strategy != "custom" ? true : alltrue([ + for group_name, group in var.grouping.custom.groups : ( + can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", group_name)) && + length(group.runner_configs) > 0 + ) + ]) + error_message = "Custom group names must be stable, safe identifiers of at most 64 characters, and every group must contain at least one runner config." + } + + precondition { + condition = var.grouping.strategy != "custom" ? true : ( + length(local.custom_members) == length(distinct(local.custom_members)) && + length(setsubtract(toset(local.custom_members), local.configured_runner_names)) == 0 && + length(setsubtract(local.configured_runner_names, toset(local.custom_members))) == 0 + ) + error_message = "Custom groups must contain every runner config exactly once and cannot contain unknown runner configs." + } + + precondition { + condition = alltrue([ + for group_name, runner_names in local.controller_groups : ( + can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", group_name)) && + length(runner_names) > 0 && + length(runner_names) <= 1000 + ) + ]) + error_message = "Resolved controller groups must have safe, non-empty, plan-known names and contain at most 1000 runner configs." + } + + precondition { + condition = alltrue([ + for group_name, decoded_bytes in local.group_reconciler_config_bytes : decoded_bytes <= 4 * 1024 * 1024 + ]) + error_message = "A controller group's decoded reconciler configuration must not exceed the runtime's 4 MiB aggregate limit. Split the group or reduce provider configuration size." + } + + precondition { + condition = alltrue([ + for group_name, environment_bytes in local.group_compute_environment_bytes : environment_bytes <= 48 * 1024 + ]) + error_message = "A controller group's compute-provider environment JSON must not exceed 49152 bytes. This reserves 16 KiB of AWS's 64 KiB ECS task-definition quota for the fixed task definition; split the group or reduce provider environment settings." + } + } +} + +resource "terraform_data" "validate_runtime" { + lifecycle { + precondition { + condition = ( + var.container.image == null ? true : ( + length(trimspace(var.container.image)) > 0 && + length(regexall("[[:space:]]", var.container.image)) == 0 + )) + error_message = "Container image references must be non-empty and cannot contain whitespace." + } + + precondition { + condition = ( + can(regex("^[1-9][0-9]{0,9}(:[1-9][0-9]{0,9})?$", var.container.user)) && + var.container.health_port >= 1 && var.container.health_port <= 65535 && + (var.container.health_check_command == null ? true : ( + length(var.container.health_check_command) >= 2 && + contains(["CMD", "CMD-SHELL"], var.container.health_check_command[0]) + )) + ) + error_message = "The container must use a numeric non-root UID (and optional GID), a valid health port, and a valid ECS health-check command." + } + + precondition { + condition = var.container.health_path == "/healthz" + error_message = "container.health_path must be /healthz, the scale-set service liveness endpoint." + } + + precondition { + condition = ( + var.container.health_check_interval >= 5 && var.container.health_check_interval <= 300 && + var.container.health_check_timeout >= 2 && var.container.health_check_timeout <= 60 && + var.container.health_check_timeout < var.container.health_check_interval && + var.container.health_check_retries >= 1 && var.container.health_check_retries <= 10 && + var.container.health_check_start_period >= 0 && var.container.health_check_start_period <= 300 && + var.container.health_stale_after_seconds >= 30 && var.container.health_stale_after_seconds <= 3600 && + var.container.shutdown_timeout_seconds >= 1 && var.container.shutdown_timeout_seconds <= 119 && + var.container.session_close_timeout_seconds >= 1 && var.container.session_close_timeout_seconds <= 60 && + var.container.reconnect_initial_backoff_seconds >= 1 && var.container.reconnect_initial_backoff_seconds <= 300 && + var.container.reconnect_max_backoff_seconds >= 1 && var.container.reconnect_max_backoff_seconds <= 3600 && + var.container.reconnect_initial_backoff_seconds <= var.container.reconnect_max_backoff_seconds && + var.container.stop_timeout_seconds >= 2 && var.container.stop_timeout_seconds <= 120 && + var.container.shutdown_timeout_seconds < var.container.stop_timeout_seconds + ) + error_message = "Container health and shutdown timings must be within ECS limits, with health timeout below interval and application shutdown below task stop timeout." + } + + precondition { + condition = ( + contains(keys(local.fargate_memory_by_cpu), tostring(var.ecs.task.cpu)) && + contains(lookup(local.fargate_memory_by_cpu, tostring(var.ecs.task.cpu), []), var.ecs.task.memory) + ) + error_message = "ecs.task.cpu and ecs.task.memory must be a supported Fargate CPU/memory combination." + } + + precondition { + condition = ( + contains(["X86_64", "ARM64"], var.ecs.task.cpu_architecture) && + (var.ecs.task.ephemeral_storage == null ? true : ( + var.ecs.task.ephemeral_storage.size_in_gib >= 21 && var.ecs.task.ephemeral_storage.size_in_gib <= 200 + )) + ) + error_message = "ecs.task.cpu_architecture must be X86_64 or ARM64, and optional ephemeral storage must be between 21 and 200 GiB." + } + + precondition { + condition = ( + contains(["managed", "external"], var.ecs.cluster.mode) && + (var.ecs.cluster.mode == "external" ? ( + var.ecs.cluster.arn != null && can(regex("^arn:[^:]+:ecs:[^:]+:[0-9]{12}:cluster/.+$", var.ecs.cluster.arn)) + ) : ( + var.ecs.cluster.arn == null && + (var.ecs.cluster.name == null ? true : can(regex("^[A-Za-z0-9_-]{1,255}$", var.ecs.cluster.name))) + )) + ) + error_message = "Use a valid external ECS cluster ARN only with cluster.mode external; managed cluster names may contain letters, digits, underscores, and hyphens." + } + + precondition { + condition = ( + startswith(var.ecs.iam.path, "/") && + endswith(var.ecs.iam.path, "/") && + length(var.ecs.iam.path) <= 512 + ) + error_message = "ecs.iam.path must start and end with a slash and be at most 512 characters." + } + + precondition { + condition = ( + length(var.network.vpc_id) > 0 && + length(var.network.subnet_ids) > 0 && + length(var.network.https_egress.ipv4_cidrs) + length(var.network.https_egress.ipv6_cidrs) > 0 && + alltrue([for cidr in var.network.https_egress.ipv4_cidrs : can(cidrnetmask(cidr))]) && + alltrue([for cidr in var.network.https_egress.ipv6_cidrs : can(cidrhost(cidr, 0)) && can(regex(":", cidr))]) + ) + error_message = "network must select a VPC and at least one subnet, and HTTPS egress must contain valid IPv4 or IPv6 CIDRs." + } + + precondition { + condition = ( + contains(["STANDARD", "INFREQUENT_ACCESS"], var.logging.log_group_class) && + contains([1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.logging.retention_in_days) && + (var.logging.kms_key_arn == null ? true : can(regex("^arn:[^:]+:kms:[^:]+:[0-9]{12}:key/.+$", var.logging.kms_key_arn))) + ) + error_message = "logging must use a supported class and retention period; kms_key_arn must be a KMS key ARN when set." + } + } +} + +resource "terraform_data" "validate_config_store" { + lifecycle { + precondition { + condition = ( + contains(["Standard", "Advanced"], var.config_store.tier) && + startswith(local.config_store_path_prefix, "/") && + !endswith(local.config_store_path_prefix, "/") && + length(local.config_store_path_prefix) >= 2 && + can(regex("^/[A-Za-z0-9_.\\/-]+$", local.config_store_path_prefix)) + ) + error_message = "config_store must use Standard or Advanced tier and a valid absolute SSM path prefix without a trailing slash." + } + + precondition { + condition = alltrue([ + for config_key, config in local.reconciler_configs : ( + length("${local.config_store_path_prefix}/${config.group_name}/${config.runner_name}") <= 1011 && + local.reconciler_config_bytes[config_key] <= local.config_store_max_bytes + ) + ]) + error_message = "Each reconciler SSM parameter name and encoded JSON value must fit the selected Parameter Store tier. Split large controller groups or reduce provider configuration when necessary." + } + } +} + +resource "terraform_data" "validate_group_task_policy" { + for_each = local.controller_groups + + lifecycle { + precondition { + condition = local.group_task_policy_bytes[each.key] <= 10240 + error_message = "Controller group ${each.key} produces a ${local.group_task_policy_bytes[each.key]}-byte task-role policy, exceeding AWS's 10240-byte inline role-policy quota. Split the group or reduce provider IAM statements." + } + } +} diff --git a/modules/orchestration-providers/scale-set/variables.tf b/modules/orchestration-providers/scale-set/variables.tf new file mode 100644 index 0000000000..c1b854d5f2 --- /dev/null +++ b/modules/orchestration-providers/scale-set/variables.tf @@ -0,0 +1,201 @@ +variable "prefix" { + description = "Stable prefix used for scale-set controller resources." + type = string + default = "github-actions" + nullable = false +} + +variable "runner_configs" { + description = <<-EOT + Normalized scale-set runner configurations keyed by stable runner-config name. + + Map keys must be known during planning. Credential values are never accepted: `github.app` contains only the exact GitHub App Parameter Store references used by the runtime. `github.ssl_verify` applies TLS verification per reconciler without changing process-global TLS behavior. Parameter and optional KMS ARNs, scale-set IDs, and other inner values may remain unknown until apply. + EOT + type = 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) + })) + nullable = false +} + +variable "compute_provider_contracts" { + description = <<-EOT + Provider-neutral, scale-set capability fragments keyed exactly like `runner_configs`. + + `type` is the plan-known provider discriminator used by the default grouping implementation and the runtime adapter registry. `configuration_json` is provider-owned, valid JSON and must contain no secrets. `environment_variables` contains non-secret provider process settings shared by every reconciler in the same controller group; conflicting values are rejected. IAM statement map keys and optional condition shapes must be known during planning; their action, resource, and condition values may be computed. + EOT + type = 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) + })), []) + })), {}) + }) + }) + })) + nullable = false +} + +variable "grouping" { + description = <<-EOT + 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. + + The strategy, custom group keys, and memberships select Terraform `for_each` instances and must be known during planning. + EOT + type = object({ + strategy = optional(string, "compute_provider") + custom = optional(object({ + groups = map(object({ + runner_configs = set(string) + })) + }), null) + }) + default = {} + nullable = false +} + +variable "container" { + description = "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." + type = 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) + }) + default = {} + nullable = false +} + +variable "config_store" { + description = <<-EOT + Non-secret controller configuration storage. The module writes one SSM String parameter per reconciler below `path_prefix//`. The task receives only its group path and a SHA-256 revision, then loads the group with `GetParametersByPath`. + + Standard parameters are limited to 4096 encoded bytes and Advanced parameters to 8192 encoded bytes. Null `path_prefix` resolves to `//scale-set-controller`. + EOT + type = object({ + path_prefix = optional(string, null) + tier = optional(string, "Standard") + tags = optional(map(string), {}) + }) + default = {} + nullable = false +} + +variable "ecs" { + description = <<-EOT + 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. + EOT + type = 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) + }), {}) + }) + default = {} + nullable = false +} + +variable "network" { + description = <<-EOT + 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. + EOT + type = 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), []) + }), {}) + }) + nullable = false +} + +variable "logging" { + description = "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." + type = object({ + retention_in_days = optional(number, 30) + kms_key_arn = optional(string, null) + log_group_class = optional(string, "STANDARD") + tags = optional(map(string), {}) + }) + default = {} + nullable = false +} + +variable "tags" { + description = "Tags applied to scale-set orchestration resources." + type = map(string) + default = {} + nullable = false +} diff --git a/modules/orchestration-providers/scale-set/versions.tf b/modules/orchestration-providers/scale-set/versions.tf new file mode 100644 index 0000000000..3ef011ea0a --- /dev/null +++ b/modules/orchestration-providers/scale-set/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index 53d0115ebb..f17e3ecd26 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -40,7 +40,7 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [config](#input\_config) | Provider-owned webhook values supplied from `orchestration_provider.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.

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

- `prefix`: Prefix used to name job-retry resources.
- `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the job-retry Lambda.
- `lambda.architecture`: Instruction-set architecture used by the job-retry Lambda.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda.
- `lambda.timeout`: Lambda timeout and retry-queue visibility timeout in seconds.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the Lambda. Use `-1` for unreserved concurrency.
- `lambda.environment_variables`: Additional Lambda environment variables. Required job-retry variables override matching keys.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the job-retry Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the Lambda role.
- `lambda.role.principals`: Extra principals allowed to assume the Lambda role, for example during local testing.
- `runner.name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `github.organization_runners`: Enables organization runners.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `queue.encryption`: Server-side encryption configuration for the retry queue.
- `ssm.kms_key_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and job-retry metric configuration.
- `tags.resources`: Tags for the job-retry Lambda role and component resources.
- `tags.lambda`: Tags for the job-retry Lambda function.
- `tags.log_group`: Tags for the job-retry log group.
- `tags.queue`: Tags for the retry queue.
- `tags.event_source_mapping`: Tags for the retry-queue event-source mapping. |
object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
encryption = object({
sqs_managed_sse_enabled = bool
kms_master_key_id = optional(string, null)
kms_data_key_reuse_period_seconds = optional(number, null)
})
})
ssm = object({
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
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.

- `prefix`: Prefix used to name job-retry resources.
- `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the job-retry Lambda.
- `lambda.architecture`: Instruction-set architecture used by the job-retry Lambda.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda.
- `lambda.timeout`: Lambda timeout and retry-queue visibility timeout in seconds.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the Lambda. Use `-1` for unreserved concurrency.
- `lambda.environment_variables`: Additional Lambda environment variables. Required job-retry variables override matching keys.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the job-retry Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the Lambda role.
- `lambda.role.principals`: Extra principals allowed to assume the Lambda role, for example during local testing.
- `runner.name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `github.organization_runners`: Enables organization runners.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `queue.encryption`: Server-side encryption configuration for the retry queue.
- `ssm.kms_key_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and job-retry metric configuration.
- `tags.resources`: Tags for the job-retry Lambda role and component resources.
- `tags.lambda`: Tags for the job-retry Lambda function.
- `tags.log_group`: Tags for the job-retry log group.
- `tags.queue`: Tags for the retry queue.
- `tags.event_source_mapping`: Tags for the retry-queue event-source mapping. |
object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = 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.

- `lambda`: Pool Lambda runtime and deployment configuration.
- `lambda.log_level`: Logging level used by the pool Lambda.
- `lambda.logging_retention_in_days`: Number of days to retain events in the pool Lambda log group.
- `lambda.logging_kms_key_id`: KMS key ID used to encrypt the pool Lambda log group.
- `lambda.log_class`: CloudWatch Logs class for the pool Lambda log group.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use -1 for no reservation.
- `lambda.s3_bucket`: S3 bucket containing the pool Lambda deployment package.
- `lambda.s3_key`: S3 key of the pool Lambda deployment package.
- `lambda.s3_object_version`: S3 object version of the pool Lambda deployment package.
- `lambda.security_group_ids`: Security group IDs associated with the pool Lambda.
- `lambda.runtime`: AWS Lambda runtime used by the pool Lambda.
- `lambda.architecture`: AWS Lambda architecture used by the pool Lambda.
- `lambda.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.timeout`: Pool Lambda timeout in seconds.
- `lambda.zip`: Local path to the pool Lambda deployment package when S3 is not used.
- `lambda.subnet_ids`: Subnet IDs in which the pool Lambda runs.
- `lambda.parameter_store_tags`: JSON-encoded tags supplied to the pool Lambda for SSM parameters it creates.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `tags`: Common tags added to pool resources.
- `ghes`: GitHub Enterprise Server connection configuration.
- `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub.
- `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate.
- `github_app_parameters`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `runner`: Runner registration configuration used by the pool Lambda.
- `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled.
- `runner.ephemeral`: Whether runners register as ephemeral runners.
- `runner.enable_jit_config`: Whether runners use just-in-time registration configuration.
- `runner.labels`: Labels assigned to runners created by the pool Lambda.
- `runner.group_name`: GitHub runner group assigned to runners created by the pool Lambda.
- `runner.name_prefix`: Prefix used for runner names.
- `runner.pool_owner`: GitHub organization or repository that owns the runner pool.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `prefix`: Prefix used to name pool resources.
- `pool`: Scheduled pool targets.
- `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target.
- `pool[*].schedule_expression_timezone`: Time zone used to evaluate the schedule expression.
- `pool[*].size`: Desired runner count for the scheduled pool target.
- `include_busy_runners`: Whether busy runners count toward the desired pool size.
- `role_permissions_boundary`: Permissions boundary applied to IAM roles created for the pool.
- `kms_key_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `ssm_config_path`: SSM path under which runner configuration is stored.
- `arn_ssm_parameters_path_config`: ARN matching the runner configuration SSM path.
- `lambda_tags`: Tags added specifically to the pool Lambda function, overriding common tags with the same key.
- `log_group_tags`: Tags added specifically to the pool Lambda log group, overriding common tags with the same key.
- `user_agent`: User-Agent header used for GitHub API requests. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
boot_time_in_minutes = number
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
log_group_tags = optional(map(string), {})
user_agent = string
})
| n/a | yes | +| [config](#input\_config) | Configuration passed from the webhook orchestration provider to the pool Lambda and scheduler.

- `lambda`: Pool Lambda runtime and deployment configuration.
- `lambda.log_level`: Logging level used by the pool Lambda.
- `lambda.logging_retention_in_days`: Number of days to retain events in the pool Lambda log group.
- `lambda.logging_kms_key_id`: KMS key ID used to encrypt the pool Lambda log group.
- `lambda.log_class`: CloudWatch Logs class for the pool Lambda log group.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use -1 for no reservation.
- `lambda.s3_bucket`: S3 bucket containing the pool Lambda deployment package.
- `lambda.s3_key`: S3 key of the pool Lambda deployment package.
- `lambda.s3_object_version`: S3 object version of the pool Lambda deployment package.
- `lambda.security_group_ids`: Security group IDs associated with the pool Lambda.
- `lambda.runtime`: AWS Lambda runtime used by the pool Lambda.
- `lambda.architecture`: AWS Lambda architecture used by the pool Lambda.
- `lambda.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.timeout`: Pool Lambda timeout in seconds.
- `lambda.zip`: Local path to the pool Lambda deployment package when S3 is not used.
- `lambda.subnet_ids`: Subnet IDs in which the pool Lambda runs.
- `lambda.parameter_store_tags`: JSON-encoded tags supplied to the pool Lambda for SSM parameters it creates.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `tags`: Common tags added to pool resources.
- `ghes`: GitHub Enterprise Server connection configuration.
- `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub.
- `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate.
- `github_app_parameters`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `runner`: Runner registration configuration used by the pool Lambda.
- `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled.
- `runner.ephemeral`: Whether runners register as ephemeral runners.
- `runner.enable_jit_config`: Whether runners use just-in-time registration configuration.
- `runner.labels`: Labels assigned to runners created by the pool Lambda.
- `runner.group_name`: GitHub runner group assigned to runners created by the pool Lambda.
- `runner.name_prefix`: Prefix used for runner names.
- `runner.pool_owner`: GitHub organization or repository that owns the runner pool.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `prefix`: Prefix used to name pool resources.
- `pool`: Scheduled pool targets.
- `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target.
- `pool[*].schedule_expression_timezone`: Time zone used to evaluate the schedule expression.
- `pool[*].size`: Desired runner count for the scheduled pool target.
- `include_busy_runners`: Whether busy runners count toward the desired pool size.
- `role_permissions_boundary`: Permissions boundary applied to IAM roles created for the pool.
- `kms_key_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `ssm_config_path`: SSM path under which runner configuration is stored.
- `arn_ssm_parameters_path_config`: ARN matching the runner configuration SSM path.
- `lambda_tags`: Tags added specifically to the pool Lambda function, overriding common tags with the same key.
- `log_group_tags`: Tags added specifically to the pool Lambda log group, overriding common tags with the same key.
- `user_agent`: User-Agent header used for GitHub API requests. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = 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.

- `type`: Compute provider type passed to scheduled pool invocations.
- `environment_variables`: Provider-specific environment variables added to the pool Lambda.
- `iam_policy_json`: Provider-specific IAM policy document merged into the pool Lambda policy.
- `managed_policy_enabled`: Whether to attach a provider-specific managed IAM policy to the pool Lambda role.
- `managed_policy_arn`: ARN of the provider-specific managed IAM policy to attach when enabled. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | | [tracing\_config](#input\_tracing\_config) | Tracing configuration for the pool Lambda.

- `mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `capture_error`: Whether Powertools tracing captures errors as tracing metadata. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | diff --git a/modules/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.

- `prefix`: Prefix used to name scaling resources.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by both scaling Lambdas.
- `lambda.architecture`: Instruction-set architecture used by both scaling Lambdas.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the scaling Lambda roles.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the scaling Lambda roles.
- `lambda.role.principals`: Additional principals allowed to assume the scaling Lambda roles.
- `runner.os`: Runner operating system used for the minimum-runtime default.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Enables or disables just-in-time runner configuration.
- `runner.labels`: Labels supplied when a runner is registered.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down.
- `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration.
- `github.organization_runners`: Registers organization runners when true.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS verification for GitHub Enterprise Server.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `ssm.token_path`: Parameter Store path used for registration tokens.
- `ssm.token_path_arn`: ARN of the Parameter Store path used for registration tokens.
- `ssm.config_path`: Parameter Store path used for persistent runner configuration.
- `ssm.config_path_arn`: ARN of the persistent runner configuration path.
- `ssm.kms_key_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `ssm.parameter_store_tags`: JSON-encoded tags applied to parameters created at runtime.
- `observability.logs`: Shared logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and GitHub rate-limit metric configuration.
- `scale_up`: Scale-up Lambda sizing, concurrency, queued-job behavior, and resolved resource tag maps.
- `scale_up.tags.resources`: Tags for the scale-up IAM role and other component resources.
- `scale_up.tags.lambda`: Tags for the scale-up Lambda function.
- `scale_up.tags.log_group`: Tags for the scale-up log group.
- `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping.
- `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps.
- `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule.
- `scale_down.tags.lambda`: Tags for the scale-down Lambda function.
- `scale_down.tags.log_group`: Tags for the scale-down log group.
- `job_retry.enabled`: Enables publishing retry checks from scale-up.
- `job_retry.queue`: Retry queue ARN and URL. Required when job retry is enabled.
- `job_retry.max_attempts`: Maximum queued-job retry attempts.
- `job_retry.delay_in_seconds`: Initial delay before checking the queued job.
- `job_retry.delay_backoff`: Multiplier applied to subsequent delays. |
object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
boot_time_in_minutes = number
maximum_count = number
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
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.

- `prefix`: Prefix used to name scaling resources.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by both scaling Lambdas.
- `lambda.architecture`: Instruction-set architecture used by both scaling Lambdas.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the scaling Lambda roles.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the scaling Lambda roles.
- `lambda.role.principals`: Additional principals allowed to assume the scaling Lambda roles.
- `runner.os`: Runner operating system used for the minimum-runtime default.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Enables or disables just-in-time runner configuration.
- `runner.labels`: Labels supplied when a runner is registered.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down.
- `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration.
- `github.organization_runners`: Registers organization runners when true.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS verification for GitHub Enterprise Server.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `ssm.token_path`: Parameter Store path used for registration tokens.
- `ssm.token_path_arn`: ARN of the Parameter Store path used for registration tokens.
- `ssm.config_path`: Parameter Store path used for persistent runner configuration.
- `ssm.config_path_arn`: ARN of the persistent runner configuration path.
- `ssm.kms_key_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `ssm.parameter_store_tags`: JSON-encoded tags applied to parameters created at runtime.
- `observability.logs`: Shared logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and GitHub rate-limit metric configuration.
- `scale_up`: Scale-up Lambda sizing, concurrency, queued-job behavior, and resolved resource tag maps.
- `scale_up.tags.resources`: Tags for the scale-up IAM role and other component resources.
- `scale_up.tags.lambda`: Tags for the scale-up Lambda function.
- `scale_up.tags.log_group`: Tags for the scale-up log group.
- `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping.
- `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps.
- `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule.
- `scale_down.tags.lambda`: Tags for the scale-down Lambda function.
- `scale_down.tags.log_group`: Tags for the scale-down log group.
- `job_retry.enabled`: Enables publishing retry checks from scale-up.
- `job_retry.queue`: Retry queue ARN and URL. Required when job retry is enabled.
- `job_retry.max_attempts`: Maximum queued-job retry attempts.
- `job_retry.delay_in_seconds`: Initial delay before checking the queued job.
- `job_retry.delay_backoff`: Multiplier applied to subsequent delays. |
object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
boot_time_in_minutes = number
maximum_count = number
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = 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.

- `type`: Compute-provider discriminator supplied to both Lambdas.
- `scale_up.environment_variables`: Provider-specific scale-up environment variables.
- `scale_up.iam_policy_json`: Provider-specific IAM policy merged into the common scale-up policy.
- `scale_up.additional_iam_policy_json`: Optional additional provider policy attached separately to the scale-up role.
- `scale_up.managed_policy`: Optional provider-managed policy attachment. Object presence controls attachment creation.
- `scale_up.managed_policy.arn`: ARN of the provider-managed policy. The ARN may remain unknown until apply.
- `scale_down.environment_variables`: Provider-specific scale-down environment variables.
- `scale_down.iam_policy_json`: Provider-specific IAM policy merged into the common scale-down policy. |
object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = string
additional_iam_policy_json = optional(string, null)
managed_policy = optional(object({
arn = string
}), null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = string
})
})
| n/a | yes | ## Outputs diff --git a/modules/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.

Exactly one compute-provider block must be non-null. The populated block selects the provider, and its presence must be known during planning. Values inside the selected block may remain unknown until apply.

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

- `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `user_agent`: Optional User-Agent value added to GitHub API requests. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = 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.

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the control-plane Lambda functions. Supported values are `arm64` and `x86_64`.
- `subnet_ids`: Subnets used for Lambda VPC configuration.
- `security_group_ids`: Security groups used for Lambda VPC configuration.
- `tags`: Shared tags applied to Lambda function resources only. These override module-level `tags`; component `tags` override this map when keys conflict.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `role.path`: IAM path for module-managed Lambda execution roles. Defaults to a path derived from `prefix`.
- `role.permissions_boundary`: Permissions-boundary ARN applied to module-managed Lambda execution roles. |
object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| `{}` | no | +| [observability](#input\_observability) | Logging, tracing, and metrics configuration for control-plane and provider resources.

- `logs.level`: Application log level supplied to the control-plane functions.
- `logs.retention_in_days`: CloudWatch Logs retention period.
- `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt CloudWatch log groups.
- `logs.class`: CloudWatch log-group class. Supported values are `STANDARD` and `INFREQUENT_ACCESS`.
- `logs.tags`: Shared tags for CloudWatch log groups. These override module-level `tags`; component `tags` override this map when keys conflict.
- `tracing.mode`: Optional Lambda active-tracing mode. Null disables X-Ray tracing configuration.
- `tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `tracing.capture_error`: Enables error capture in the tracing helper.
- `metrics.enabled`: Enables module-emitted metrics.
- `metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `metrics.metric.github_app_rate_limit.enabled`: Emits GitHub App rate-limit metrics.
- `metrics.metric.job_retry.enabled`: Emits job-retry metrics.
- `metrics.metric.spot_termination_warning.enabled`: Emits spot-termination warning metrics where supported. |
object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(object({
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.

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

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture, such as `x64` or `arm64`.
- `disable_default_labels`: Prevents GitHub's default self-hosted, operating-system, and architecture labels from being registered.
- `labels`: Complete set of labels supplied to the control-plane functions.
- `group_name`: GitHub runner group used during registration.
- `name_prefix`: Prefix added to registered runner names.
- `run_as_root`: Runs the runner service as root when supported by the compute provider.
- `run_as`: Operating-system user used when `run_as_root` is false.
- `auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `tags`: Additional tags for common runner resources, currently the managed runner IAM role. These override module-level `tags` with the same key.
- `hooks.job_started`: Script content installed as the runner job-started hook.
- `hooks.job_completed`: Script content installed as the runner job-completed hook.
- `iam.role.arn`: ARN of an externally managed runner role. When set, this module does not create or modify that role.
- `iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role.
- `iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `iam.path`: IAM path for the module-managed runner role. Defaults to a path derived from `prefix`.
- `iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
disable_default_labels = optional(bool, false)
labels = list(string)
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| n/a | yes | +| [ssm](#input\_ssm) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.

- `paths.root`: Root Parameter Store path for this runner configuration.
- `paths.tokens`: Path segment under `paths.root` used for registration tokens and just-in-time configuration.
- `paths.config`: Path segment under `paths.root` used for persistent runner configuration.
- `kms_key_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters.
- `tags`: Shared tags for SSM-related resources. These override module-level `tags` and are inherited by parameter and housekeeper resources.
- `parameters.tags`: Tags for Terraform-managed runner configuration parameters and temporary parameters created by the scale-up and pool Lambdas. These override module-level and `ssm.tags` values with the same key.
- `housekeeper.schedule_expression`: EventBridge schedule expression that invokes the SSM housekeeper.
- `housekeeper.state`: EventBridge rule state, such as `ENABLED` or `DISABLED`.
- `housekeeper.tags`: Tags for housekeeper resources, including the Lambda function, log group, EventBridge rule, and IAM role. These override module-level, `ssm.tags`, shared Lambda, and shared log tags when keys conflict.
- `housekeeper.lambda.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact.
- `housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket.
- `housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `housekeeper.config.tokenPath`: Parameter Store token path cleaned by the housekeeper. When omitted, the configured runner token path is used.
- `housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. |
object({
paths = object({
root = string
tokens = string
config = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
})
| n/a | yes | +| [tags](#input\_tags) | Base tags added to taggable resources created by this runner configuration. Shared, component, and compute-provider tag maps override matching keys within their documented resource scopes. | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [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.

- `prefix`: Prefix used to name the housekeeper resources.
- `aws_partition`: AWS partition used to construct IAM policy ARNs.
- `schedule.expression`: EventBridge schedule expression that invokes the housekeeper.
- `schedule.state`: State of the EventBridge rule.
- `cleanup.token_path`: Parameter Store token path supplied to the Lambda.
- `cleanup.parameter_path_arn`: IAM resource ARN matching `cleanup.token_path`.
- `cleanup.minimum_days_old`: Minimum parameter age before deletion.
- `cleanup.dry_run`: Reports eligible parameters without deleting them when true.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the housekeeper Lambda.
- `lambda.architecture`: Instruction-set architecture used by the housekeeper Lambda.
- `lambda.memory_size`: Memory allocated to the housekeeper Lambda.
- `lambda.timeout`: Housekeeper Lambda timeout in seconds.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the housekeeper Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the housekeeper role.
- `lambda.role.principals`: Additional principals allowed to assume the housekeeper Lambda role.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `tags.resources`: Tags for the housekeeper role and EventBridge rule.
- `tags.lambda`: Tags for the housekeeper Lambda function.
- `tags.log_group`: Tags for the housekeeper log group. |
object({
prefix = string
aws_partition = string
schedule = object({
expression = string
state = string
})
cleanup = object({
token_path = string
parameter_path_arn = string
minimum_days_old = number
dry_run = bool
})
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
| n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [housekeeper](#output\_housekeeper) | SSM housekeeper Lambda resources. | + diff --git a/modules/runner-config/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 = + error_message = "Explanation of what failed" + } +} +``` + +## CI integration + +These tests run automatically in the `terraform_test` job of `.github/workflows/terraform.yml` on every PR that touches `*.tf` or `*.hcl` files. diff --git a/modules/runner-config/tests/computed-iam-inputs.tftest.hcl b/modules/runner-config/tests/computed-iam-inputs.tftest.hcl new file mode 100644 index 0000000000..9fcea735ed --- /dev/null +++ b/modules/runner-config/tests/computed-iam-inputs.tftest.hcl @@ -0,0 +1,35 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } +} + +run "computed_external_values_keep_plan_shape_known" { + command = plan + + module { + source = "./tests/fixtures/computed-iam-inputs" + } + + # The packaged runner archive is added by the release build, so the computed + # IAM fixture isolates the two common housekeeper children in a source checkout. + override_module { + target = module.external_iam.module.ssm_housekeeper + } + + override_module { + target = module.generated_policy.module.ssm_housekeeper + } + + assert { + condition = output.external_role_runner_count == 0 + error_message = "Computed external AMI parameter, KMS key, role, and profile values must not make resource or policy-block counts unknown." + } + + assert { + condition = output.generated_policy_role_runner_count == 1 + error_message = "A computed managed-policy ARN under a caller-known map key must keep attachment planning stable." + } +} diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md new file mode 100644 index 0000000000..3bbf0f9027 --- /dev/null +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md @@ -0,0 +1,39 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3 | +| [random](#requirement\_random) | ~> 3.0 | + +## Providers + +| Name | Version | +|------|---------| +| [random](#provider\_random) | ~> 3.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [external\_iam](#module\_external\_iam) | ../../.. | n/a | +| [generated\_policy](#module\_generated\_policy) | ../../.. | n/a | + +## Resources + +| Name | Type | +|------|------| +| [random_id.external](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | +| [random_id.generated_policy](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | + +## Inputs + +No inputs. + +## Outputs + +| Name | Description | +|------|-------------| +| [external\_role\_runner\_count](#output\_external\_role\_runner\_count) | n/a | +| [generated\_policy\_role\_runner\_count](#output\_generated\_policy\_role\_runner\_count) | n/a | + diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf new file mode 100644 index 0000000000..22fad85023 --- /dev/null +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -0,0 +1,212 @@ +# A .tftest.hcl variable block supplies plan-known values. This wrapper uses +# random_id results to exercise caller inputs that remain unknown during plan, +# which catches invalid count, for_each, and dynamic-block expressions in the +# IAM boundary. +resource "random_id" "external" { + byte_length = 4 +} + +resource "random_id" "generated_policy" { + byte_length = 4 +} + +module "external_iam" { + source = "../../.." + + aws_region = "eu-west-1" + prefix = "computed-external" + + compute_provider = { + aws = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/external-ami-${random_id.external.hex}" + } + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/${random_id.external.hex}" + } + } + instance_profile = { + name = "external-runner-${random_id.external.hex}" + } + cloudwatch_agent = { + enabled = false + } + binaries_syncer = { + enabled = false + } + } + } + } + + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external-runner-${random_id.external.hex}" + } + } + } + + lambda = { + artifact = { + s3 = { + bucket = "lambda-artifacts" + } + } + } + + github = { + app_parameters = { + key_base64 = [{ + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + }] + id = [{ + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + }] + } + } + + orchestration_provider = { + webhook = { + runner = { + maximum_count = 3 + } + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-external" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-external" + } + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/build-queue-${random_id.external.hex}" + } + lambda = { + artifact = { + s3 = { + key = "runners.zip" + } + } + pool = { + runner_owner = "example" + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + } + job_retry = { + enabled = true + } + } + } + + ssm = { + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/${random_id.external.hex}" + paths = { + root = "/github-runner/computed-external" + tokens = "tokens" + config = "config" + } + } +} + +module "generated_policy" { + source = "../../.." + + aws_region = "eu-west-1" + prefix = "computed-policy" + + compute_provider = { + aws = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + cloudwatch_agent = { + enabled = false + } + binaries_syncer = { + enabled = false + } + } + } + } + + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + managed_policy_arns = { + generated = "arn:aws:iam::123456789012:policy/generated-runner-${random_id.generated_policy.hex}" + } + } + } + + lambda = { + artifact = { + s3 = { + bucket = "lambda-artifacts" + } + } + } + + github = { + app_parameters = { + key_base64 = [{ + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + }] + id = [{ + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + }] + } + } + + orchestration_provider = { + webhook = { + runner = { + maximum_count = 3 + } + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:computed-policy" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/computed-policy" + } + } + lambda = { + artifact = { + s3 = { + key = "runners.zip" + } + } + } + } + } + + ssm = { + paths = { + root = "/github-runner/computed-policy" + tokens = "tokens" + config = "config" + } + } +} + +output "external_role_runner_count" { + value = module.external_iam.runner.role == null ? 0 : 1 +} + +output "generated_policy_role_runner_count" { + value = module.generated_policy.runner.role == null ? 0 : 1 +} diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/versions.tf b/modules/runner-config/tests/fixtures/computed-iam-inputs/versions.tf new file mode 100644 index 0000000000..9fd85fad8f --- /dev/null +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.3" + + required_providers { + aws = { + source = "hashicorp/aws" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} diff --git a/modules/runner-config/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl new file mode 100644 index 0000000000..8c63c7c12b --- /dev/null +++ b/modules/runner-config/tests/pool.tftest.hcl @@ -0,0 +1,653 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/runner-test" + } + } + + mock_resource "aws_ssm_parameter" { + defaults = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" + } + } +} + +# The runner archive is injected during packaging, so isolate the common +# housekeeper child in source-checkout tests where that build artifact is absent. +override_module { + target = module.ssm_housekeeper +} + +variables { + aws_region = "eu-west-1" + + compute_provider = { + aws = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" + } + kms_key = null + } + binaries_syncer = { + s3 = { + arn = "arn:aws:s3:::my-bucket" + id = "my-bucket" + key = "runners/linux/actions-runner.tar.gz" + } + } + ssm_enabled = true + } + } + } + + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } + additional_trust_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AdditionalTrustedAccount" + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { AWS = "arn:aws:iam::210987654321:root" } + }] + }) + } + } + + lambda = { + artifact = { + s3 = { + bucket = "my-lambda-bucket" + } + } + } + + github = { + app_parameters = { + key_base64 = [{ name = "/github-runner/key-base64", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" }] + id = [{ name = "/github-runner/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" }] + installation_id = [null] + } + } + + orchestration_provider = { + webhook = { + runner = { + boot_time_in_minutes = 8 + ephemeral = true + jit_config_enabled = null + maximum_count = 9 + } + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + } + lambda = { + artifact = { + s3 = { + key = "runners.zip" + } + } + pool = { + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + } + } + } + } + + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + } + +} + +run "plan_with_pool_enabled" { + command = plan + + assert { + condition = module.orchestration_webhook[0].pool != null + error_message = "Pool module should be enabled when pool.config is non-empty" + } + + assert { + condition = ( + !contains(keys(var.runner), "maximum_count") + && !contains(keys(var.runner), "boot_time_in_minutes") + && !contains(keys(var.runner), "ephemeral") + && !contains(keys(var.runner), "jit_config_enabled") + && var.orchestration_provider.webhook.runner.boot_time_in_minutes == 8 + && var.orchestration_provider.webhook.runner.ephemeral + && var.orchestration_provider.webhook.runner.jit_config_enabled == null + && var.orchestration_provider.webhook.runner.maximum_count == 9 + && module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" + && module.orchestration_webhook[0].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" + && module.orchestration_webhook[0].pool.lambda.environment[0].variables["RUNNERS_MAXIMUM_COUNT"] == "9" + && module.orchestration_webhook[0].pool.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" + ) + error_message = "Runner capacity and boot time must be owned by orchestration_provider.webhook.runner and routed to webhook controls, not retained in the common runner contract." + } + + assert { + condition = ( + aws_ssm_parameter.runner_agent_mode.value == "ephemeral" + && aws_ssm_parameter.jit_config_enabled.value == "true" + ) + error_message = "Runner-config must serialize the webhook provider's resolved lifecycle contract without duplicating its JIT fallback." + } + + assert { + condition = ( + module.orchestration_webhook[0].scale_up.lambda.s3_bucket == "my-lambda-bucket" + && module.orchestration_webhook[0].scale_up.lambda.s3_key == "runners.zip" + && local.ssm_housekeeper_artifact.s3.bucket == null + && endswith(local.ssm_housekeeper_artifact.zip, "/lambdas/functions/control-plane/runners.zip") + ) + error_message = "The webhook provider must combine its artifact key with the shared bucket while the common SSM housekeeper remains on the packaged archive." + } + + assert { + condition = ( + toset(keys(output.provider)) == toset(["aws"]) + && toset(keys(output.provider.aws)) == toset(["ec2"]) + ) + error_message = "The runner configuration must expose resources under the selected provider namespace and type." + } + + assert { + condition = contains(keys(output.provider.aws.ec2), "launch_template") + error_message = "The runner configuration must expose EC2 resources only under provider.aws.ec2." + } + + assert { + condition = length(aws_iam_role.runner) == 1 && output.runner.role != null + error_message = "The common runner configuration must create and expose the runner role." + } + + assert { + condition = ( + length(module.compute_aws_ec2_trust_policy) == 1 + && aws_iam_role.runner[0].assume_role_policy == module.compute_aws_ec2_trust_policy[0].assume_role_policy + ) + error_message = "The common runner role must use the selected EC2 trust-policy submodule output." + } + + assert { + condition = ( + output.pool != null + && toset(keys(output.pool)) == toset(["lambda", "log_group", "role"]) + ) + error_message = "An enabled pool must expose its Lambda, log group, and role through the nested pool output." + } + + assert { + condition = ( + toset(keys(output.orchestration_provider)) == toset(["scale_set", "webhook"]) + && output.orchestration_provider.webhook != null + && output.orchestration_provider.scale_set == null + && output.orchestration_provider.webhook.scale_up != null + && output.orchestration_provider.webhook.scale_down != null + && output.orchestration_provider.webhook.pool != null + ) + error_message = "The canonical orchestration output must group the existing webhook control-plane resources while flat aliases remain available." + } + + assert { + condition = length(jsondecode(module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"])) == 0 + error_message = "Runtime Parameter Store tags must remain empty when no module or SSM tags are configured; EC2 bootstrap tags must not leak into them." + } + + assert { + condition = !contains(keys(output.provider.aws.ec2), "role_runner") + error_message = "The common runner role must not be duplicated in the EC2 resource output." + } + + assert { + condition = toset(keys(aws_iam_role_policy.runner_provider)) == toset([ + "ssm_parameters", + "describe_tags", + "create_tags", + "terminate_self", + "session_manager", + "distribution_bucket", + "cloudwatch", + ]) + error_message = "The common runner configuration must attach every enabled EC2 runner policy by its stable provider key." + } + + assert { + condition = aws_iam_role_policy_attachment.runner["user-readonly"].policy_arn == "arn:aws:iam::aws:policy/ReadOnlyAccess" + error_message = "The selected EC2 provider contract must return common managed runner policies for one attachment path." + } + + assert { + condition = ( + module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" + && module.orchestration_webhook[0].scale_down.lambda.environment[0].variables["COMPUTE_PROVIDER_TYPE"] == "ec2" + ) + error_message = "Scaling Lambdas must receive the provider type from the selected provider." + } + + assert { + condition = module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["INSTANCE_TYPES"] == "m5.large" + error_message = "Scale-up must merge the EC2 environment fragment." + } + + assert { + condition = module.orchestration_webhook[0].scale_down.lambda.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "8" + error_message = "Scale-down must receive boot time from the webhook orchestration configuration." + } + + assert { + condition = ( + toset(keys(module.orchestration_webhook[0].scale_up)) == toset(["lambda", "log_group", "role"]) + && toset(keys(module.orchestration_webhook[0].scale_down)) == toset(["lambda", "log_group", "role"]) + ) + error_message = "The scale-runners child module must forward the nested scale-up and scale-down resource contracts." + } + +} + +run "housekeeper_uses_component_s3_artifact" { + command = plan + + variables { + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + housekeeper = { + lambda = { + artifact = { + s3 = { + key = "housekeeper/runner-config.zip" + object_version = "housekeeper-version" + } + } + } + } + } + } + + assert { + condition = ( + local.ssm_housekeeper_artifact.zip == null + && local.ssm_housekeeper_artifact.s3.bucket == "my-lambda-bucket" + && local.ssm_housekeeper_artifact.s3.key == "housekeeper/runner-config.zip" + && local.ssm_housekeeper_artifact.s3.object_version == "housekeeper-version" + ) + error_message = "The SSM housekeeper must combine its component-owned S3 key and version with the common Lambda artifact bucket." + } +} + +run "housekeeper_uses_component_local_zip" { + command = plan + + variables { + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + } + } + } + } + } + + assert { + condition = ( + local.ssm_housekeeper_artifact.zip == "README.md" + && local.ssm_housekeeper_artifact.s3.bucket == null + && local.ssm_housekeeper_artifact.s3.key == null + && local.ssm_housekeeper_artifact.s3.object_version == null + ) + error_message = "The SSM housekeeper must use its component-owned local zip without inheriting the common bucket or webhook artifact." + } +} + +run "rejects_conflicting_housekeeper_artifacts" { + command = plan + + variables { + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + housekeeper = { + lambda = { + artifact = { + zip = "README.md" + s3 = { + key = "housekeeper/runner-config.zip" + } + } + } + } + } + } + + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] +} + +run "rejects_housekeeper_s3_without_common_bucket" { + command = plan + + variables { + lambda = { + artifact = { + s3 = { + bucket = null + } + } + } + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + housekeeper = { + lambda = { + artifact = { + s3 = { + key = "housekeeper/runner-config.zip" + } + } + } + } + } + } + + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] +} + +run "rejects_missing_orchestration_provider" { + command = plan + + variables { + orchestration_provider = { + webhook = null + } + } + + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] +} + +run "external_runner_role_is_not_managed_by_common" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } + } + } + } + + assert { + condition = length(aws_iam_role.runner) == 0 && length(aws_iam_role_policy.runner_provider) == 0 && length(aws_iam_role_policy_attachment.runner) == 0 + error_message = "An external runner role must remain unmanaged by the common runner configuration." + } + + assert { + condition = output.runner.role == null + error_message = "The nested runner role output must be null when an external role is selected." + } + + + assert { + condition = output.provider.aws.ec2.launch_template.iam_instance_profile[0].name == "github-actions-runner-profile" + error_message = "EC2 must create an instance profile around an externally supplied runner role when no profile override is provided." + } +} + +run "external_runner_role_and_profile_remain_external" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } + } + } + compute_provider = { + aws = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + instance_profile = { + name = "external-runner-profile" + } + binaries_syncer = { + enabled = false + } + } + } + } + } + + assert { + condition = length(aws_iam_role.runner) == 0 && length(aws_iam_role_policy.runner_provider) == 0 + error_message = "The common runner configuration must not manage an external role." + } + + assert { + condition = output.provider.aws.ec2.launch_template.iam_instance_profile[0].name == "external-runner-profile" + error_message = "The EC2 launch template must use the external instance profile." + } +} + +run "empty_runner_iam_uses_common_role" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = {} + } + } + + assert { + condition = length(aws_iam_role.runner) == 1 + error_message = "An empty runner.iam object must use common role ownership." + } +} + +run "external_role_rejects_managed_policy_attachments" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } + managed_policy_arns = { + readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess" + } + } + } + } + + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] +} + +run "external_role_rejects_trust_policy_extension" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/external/runner-role" + } + additional_trust_policy_json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + } + + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] +} + +run "rejects_invalid_trust_policy_extension" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + iam = { + additional_trust_policy_json = "{" + } + } + } + + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] +} + +run "rejects_empty_compute_provider" { + command = plan + + variables { + compute_provider = {} + } + + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] +} + +run "rejects_empty_aws_compute_provider_namespace" { + command = plan + + variables { + compute_provider = { + aws = {} + } + } + + plan_options { + target = [terraform_data.validate_config] + } + + expect_failures = [terraform_data.validate_config] +} + +run "job_retry_uses_common_runner_configuration_identity" { + command = plan + + variables { + runner = { + labels = ["self-hosted", "linux", "x64"] + name_prefix = "provider-neutral-" + } + orchestration_provider = { + webhook = { + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + } + lambda = { + artifact = { + s3 = { + key = "runners.zip" + } + } + } + job_retry = { + enabled = true + lambda = { + reserved_concurrent_executions = 2 + } + } + } + } + } + + assert { + condition = module.orchestration_webhook[0].job_retry.lambda.function.environment[0].variables["RUNNER_NAME_PREFIX"] == "provider-neutral-" + error_message = "Job retry must receive the common runner-configuration name prefix." + } + + assert { + condition = module.orchestration_webhook[0].job_retry.lambda.function.reserved_concurrent_executions == 2 + error_message = "Job retry must apply its configured Lambda reserved concurrency." + } +} diff --git a/modules/runner-config/tests/scale-set.tftest.hcl b/modules/runner-config/tests/scale-set.tftest.hcl new file mode 100644 index 0000000000..4132bcf236 --- /dev/null +++ b/modules/runner-config/tests/scale-set.tftest.hcl @@ -0,0 +1,139 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/runner-test" + } + } + + mock_resource "aws_ssm_parameter" { + defaults = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" + } + } +} + +# The runner archive is injected during packaging; isolate the common +# housekeeper child so this source-checkout test remains deterministic. +override_module { + target = module.ssm_housekeeper +} + +variables { + aws_region = "eu-west-1" + + runner = { + labels = ["self-hosted", "linux", "x64"] + } + + github = { + app_parameters = { + key_base64 = [{ + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + }] + id = [{ + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + }] + installation_id = [null] + } + } + + lambda = { + artifact = { + s3 = { + bucket = "test-lambda-bucket" + } + } + } + + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + } + + compute_provider = { + aws = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" + } + kms_key = null + } + binaries_syncer = { + s3 = { + arn = "arn:aws:s3:::test-runner-binaries" + id = "test-runner-binaries" + key = "runners/linux/actions-runner.tar.gz" + } + } + } + } + } + + orchestration_provider = { + scale_set = { + github = { + config_url = "https://github.com/example" + installation_id_ssm = { + name = "/github-runner/installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id" + } + } + name = "linux-scale-set" + id = 42 + min_runners = 1 + max_runners = 4 + work_folder = "_work/linux-scale-set" + } + } +} + +run "selects_scale_set_and_forces_ephemeral_jit" { + command = plan + + assert { + condition = ( + local.orchestration_provider_type == "scale_set" + && length(module.orchestration_webhook) == 0 + && output.orchestration_provider.webhook == null + && output.orchestration_provider.scale_set.name == "linux-scale-set" + && output.orchestration_provider.scale_set.id == 42 + ) + error_message = "Runner-config must select scale_set as the only orchestration provider and preserve its identity contract." + } + + assert { + condition = ( + output.scale_up == null + && output.scale_down == null + && output.pool == null + && aws_ssm_parameter.runner_agent_mode.value == "ephemeral" + && aws_ssm_parameter.jit_config_enabled.value == "true" + ) + error_message = "Scale-set orchestration must omit webhook controls and force ephemeral JIT runner registration." + } + + assert { + condition = ( + output.compute_provider_contract.type == "ec2" + && output.compute_provider_contract.capabilities.scale_set.configuration_json != "{}" + ) + error_message = "Runner-config must expose the selected compute provider's scale_set capability for the shared controller." + } +} diff --git a/modules/runner-config/tests/tags.tftest.hcl b/modules/runner-config/tests/tags.tftest.hcl new file mode 100644 index 0000000000..6c004879ba --- /dev/null +++ b/modules/runner-config/tests/tags.tftest.hcl @@ -0,0 +1,322 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/runner-test" + } + } + + mock_resource "aws_ssm_parameter" { + defaults = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/config" + } + } +} + +# The runner archive is injected during packaging, so model the common +# housekeeper output while testing parent-level tag composition from source. +override_module { + target = module.ssm_housekeeper +} + +variables { + aws_region = "eu-west-1" + + tags = { + precedence = "module" + module = "yes" + } + + compute_provider = { + aws = { + ec2 = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id" + } + kms_key = null + } + binaries_syncer = { + s3 = { + arn = "arn:aws:s3:::my-bucket" + id = "my-bucket" + key = "runners/linux/actions-runner.tar.gz" + } + } + } + } + } + + runner = { + labels = ["self-hosted", "linux", "x64"] + tags = { + precedence = "runner" + runner = "yes" + } + } + + lambda = { + artifact = { + s3 = { + bucket = "my-lambda-bucket" + } + } + tags = { + precedence = "lambda" + lambda = "yes" + } + } + + github = { + app_parameters = { + key_base64 = [{ + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + }] + id = [{ + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + }] + installation_id = [null] + } + } + + orchestration_provider = { + webhook = { + github = { + organization_runners = true + } + queue = { + build = { + arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue" + } + tags = { + precedence = "queue" + queue = "yes" + } + } + lambda = { + artifact = { + s3 = { + key = "runners.zip" + } + } + scale = { + up = { + tags = { + precedence = "scale-up" + scale_up = "yes" + } + } + down = { + tags = { + precedence = "scale-down" + scale_down = "yes" + } + } + } + pool = { + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + size = 1 + }] + tags = { + precedence = "pool" + pool = "yes" + } + } + } + job_retry = { + enabled = true + tags = { + precedence = "job-retry" + job_retry = "yes" + } + } + } + } + + ssm = { + paths = { + root = "/github-runner" + tokens = "tokens" + config = "config" + } + tags = { + precedence = "ssm" + ssm = "yes" + } + parameters = { + tags = { + precedence = "ssm-parameter" + parameter = "yes" + } + } + housekeeper = { + tags = { + precedence = "ssm-housekeeper" + housekeeper = "yes" + } + } + } + + observability = { + logs = { + level = "debug" + tags = { + precedence = "log" + log = "yes" + } + } + } +} + +run "layered_component_tags" { + command = plan + + assert { + condition = module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["LOG_LEVEL"] == "DEBUG" + error_message = "The nested observability.logs.level value must configure the control-plane functions." + } + + assert { + condition = module.orchestration_webhook[0].scale_up.lambda.tags == tomap({ + precedence = "scale-up" + module = "yes" + lambda = "yes" + scale_up = "yes" + }) && module.orchestration_webhook[0].scale_up.log_group.tags == tomap({ + precedence = "scale-up" + module = "yes" + log = "yes" + scale_up = "yes" + }) && module.orchestration_webhook[0].scale_up.role.tags == tomap({ + precedence = "scale-up" + module = "yes" + scale_up = "yes" + }) + error_message = "Scale-up tags must layer module, shared resource, and component tags with the component taking precedence." + } + + assert { + condition = module.orchestration_webhook[0].scale_down.lambda.tags == tomap({ + precedence = "scale-down" + module = "yes" + lambda = "yes" + scale_down = "yes" + }) && module.orchestration_webhook[0].scale_down.log_group.tags == tomap({ + precedence = "scale-down" + module = "yes" + log = "yes" + scale_down = "yes" + }) && module.orchestration_webhook[0].scale_down.role.tags == tomap({ + precedence = "scale-down" + module = "yes" + scale_down = "yes" + }) + error_message = "Scale-down tags must layer module, shared resource, and component tags with the component taking precedence." + } + + assert { + condition = aws_iam_role.runner[0].tags == tomap({ + precedence = "runner" + module = "yes" + runner = "yes" + }) + error_message = "Runner tags must override module tags on the common runner role." + } + + assert { + condition = aws_ssm_parameter.runner_agent_mode.tags == tomap({ + precedence = "ssm-parameter" + module = "yes" + ssm = "yes" + parameter = "yes" + }) && tomap({ + for tag in jsondecode(module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : + tag.Key => tag.Value + }) == tomap({ + precedence = "ssm-parameter" + module = "yes" + ssm = "yes" + parameter = "yes" + }) + error_message = "Terraform-managed and runtime-created SSM parameters must use the same layered parameter tags." + } + + assert { + condition = local.ssm_housekeeper_lambda_tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + lambda = "yes" + ssm = "yes" + housekeeper = "yes" + }) && local.ssm_housekeeper_log_tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + log = "yes" + ssm = "yes" + housekeeper = "yes" + }) && local.ssm_housekeeper_tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + ssm = "yes" + housekeeper = "yes" + }) + error_message = "SSM housekeeper tags must layer module, SSM, shared resource, and housekeeper tags." + } + + assert { + condition = module.orchestration_webhook[0].pool.lambda.tags == tomap({ + precedence = "pool" + module = "yes" + lambda = "yes" + pool = "yes" + }) && module.orchestration_webhook[0].pool.log_group.tags == tomap({ + precedence = "pool" + module = "yes" + log = "yes" + pool = "yes" + }) && module.orchestration_webhook[0].pool.role.tags == tomap({ + precedence = "pool" + module = "yes" + pool = "yes" + }) + error_message = "Pool tags must layer module, shared resource, and component tags with the component taking precedence." + } + + assert { + condition = module.orchestration_webhook[0].job_retry.lambda.function.tags == tomap({ + precedence = "job-retry" + module = "yes" + lambda = "yes" + job_retry = "yes" + }) && module.orchestration_webhook[0].job_retry.lambda.log_group.tags == tomap({ + precedence = "job-retry" + module = "yes" + log = "yes" + job_retry = "yes" + }) && module.orchestration_webhook[0].job_retry.lambda.role.tags == tomap({ + precedence = "job-retry" + module = "yes" + job_retry = "yes" + }) && module.orchestration_webhook[0].job_retry.queue.tags == tomap({ + precedence = "job-retry" + module = "yes" + queue = "yes" + job_retry = "yes" + }) + error_message = "Job-retry tags must layer module, shared resource, and component tags with the component taking precedence." + } +} diff --git a/modules/runner-config/validations.tf b/modules/runner-config/validations.tf new file mode 100644 index 0000000000..0c4ff5bcf0 --- /dev/null +++ b/modules/runner-config/validations.tf @@ -0,0 +1,124 @@ +resource "terraform_data" "validate_config" { + lifecycle { + precondition { + condition = contains(["linux", "osx", "windows"], var.runner.os) + error_message = "Valid values for runner.os are linux, osx, and windows." + } + + precondition { + condition = length(var.runner.name_prefix) <= 45 + error_message = "runner.name_prefix must be at most 45 characters." + } + + precondition { + condition = var.runner.iam.role == null ? true : trimspace(var.runner.iam.role.arn) != "" + error_message = "runner.iam.role.arn must be a non-empty ARN when set." + } + + precondition { + condition = var.runner.iam.role == null || length(var.runner.iam.managed_policy_arns) == 0 + error_message = "runner.iam.managed_policy_arns cannot be set with an external runner.iam.role because external roles are not managed by this module." + } + + precondition { + condition = var.runner.iam.additional_trust_policy_json == null ? true : can(jsondecode(var.runner.iam.additional_trust_policy_json)) + error_message = "runner.iam.additional_trust_policy_json must be valid JSON when set." + } + + precondition { + condition = var.runner.iam.role == null || var.runner.iam.additional_trust_policy_json == null + error_message = "runner.iam.additional_trust_policy_json cannot be set with an external runner.iam.role because external role trust is not managed by this module." + } + + precondition { + condition = contains(["arm64", "x86_64"], var.lambda.architecture) + error_message = "lambda.architecture must be arm64 or x86_64." + } + + precondition { + condition = !( + var.ssm.housekeeper.lambda.artifact.zip != null && + var.ssm.housekeeper.lambda.artifact.s3 != null + ) + error_message = "ssm.housekeeper.lambda.artifact must select at most one of zip or s3." + } + + precondition { + condition = ( + var.ssm.housekeeper.lambda.artifact.s3 == null || + var.lambda.artifact.s3.bucket != null + ) + error_message = "lambda.artifact.s3.bucket must be set when ssm.housekeeper.lambda.artifact.s3 is selected." + } + + precondition { + condition = contains(["STANDARD", "INFREQUENT_ACCESS"], var.observability.logs.class) + error_message = "observability.logs.class must be STANDARD or INFREQUENT_ACCESS." + } + + precondition { + condition = contains([ + "silly", + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + ], var.observability.logs.level) + error_message = "observability.logs.level must be one of silly, trace, debug, info, warn, error, or fatal." + } + + precondition { + condition = length([ + for provider_key, provider_config in local.compute_providers : provider_key + if provider_config != null + ]) == 1 + error_message = "Exactly one compute-provider block must be set. Supported compute-provider blocks: aws.ec2." + } + + precondition { + condition = var.compute_provider_key == null || try( + local.compute_providers[var.compute_provider_key] != null, + false, + ) + error_message = "compute_provider_key must identify the non-null typed compute-provider block." + } + + precondition { + condition = length([ + for provider_name, provider_config in var.orchestration_provider : provider_name + if provider_config != null + ]) == 1 + error_message = "Exactly one orchestration provider must be configured. Supported providers: webhook and scale_set." + } + + precondition { + condition = var.orchestration_provider.scale_set == null ? true : local.provider_contract.capabilities.scale_set != null + error_message = "The selected compute provider must expose a scale_set capability when scale_set orchestration is selected." + } + + precondition { + condition = var.orchestration_provider.webhook == null ? true : ( + var.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size >= 1 && + var.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size <= 1000 && + var.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds >= 0 && + var.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds <= 300 + ) + error_message = "orchestration_provider.webhook.lambda.scale.up.event_source_mapping batch size must be between 1 and 1000 and its batching window between 0 and 300 seconds." + } + + precondition { + condition = var.orchestration_provider.webhook == null ? true : !( + var.orchestration_provider.webhook.lambda.artifact.zip != null && + var.orchestration_provider.webhook.lambda.artifact.s3 != null + ) + error_message = "orchestration_provider.webhook.lambda.artifact must select at most one of zip or s3." + } + + precondition { + condition = var.orchestration_provider.webhook == null ? true : (!var.orchestration_provider.webhook.job_retry.enabled || var.orchestration_provider.webhook.job_retry.delay_in_seconds <= 900) + error_message = "orchestration_provider.webhook.job_retry.delay_in_seconds cannot exceed the SQS maximum of 900 seconds." + } + } +} diff --git a/modules/runner-config/variables.compute-provider.tf b/modules/runner-config/variables.compute-provider.tf new file mode 100644 index 0000000000..87c85b7c05 --- /dev/null +++ b/modules/runner-config/variables.compute-provider.tf @@ -0,0 +1,263 @@ +# Optional plan-known dispatch key supplied by the multi-runner topology layer. +variable "compute_provider_key" { + description = "Optional plan-known compute-provider dispatch key. Null discovers the key from the exactly one populated compute_provider block." + type = string + default = null + + validation { + condition = var.compute_provider_key == null ? true : contains(["aws_ec2"], var.compute_provider_key) + error_message = "compute_provider_key must be null or aws_ec2." + } +} + +# Typed compute-provider input boundary between the common control plane and compute implementations. +variable "compute_provider" { + description = <<-EOT + Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block. + + Exactly one compute-provider block must be non-null. The populated block selects the provider, and its presence must be known during planning. Values inside the selected block may remain unknown until apply. + + - `aws`: AWS compute-provider configurations. + - `aws.ec2`: EC2 compute-provider configuration. + - `aws.ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults. + - `aws.ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter. + - `aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI. + - `aws.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource. + - `aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply. + - `aws.ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator. + - `aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply. + - `aws.ec2.vpc_id`: VPC in which runner networking resources are created. + - `aws.ec2.subnet_ids`: Subnets from which scale-up may launch runner instances. + - `aws.ec2.overrides`: Optional resource-name overrides. + - `aws.ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name. + - `aws.ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name. + - `aws.ec2.instance_profile`: Optional externally managed instance profile used by the launch template. + - `aws.ec2.instance_profile.name`: Name of the externally managed instance profile. + - `aws.ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the runner-configuration prefix. + - `aws.ec2.binaries_syncer`: Runner-distribution synchronization configuration. + - `aws.ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3. + - `aws.ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled. + - `aws.ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies. + - `aws.ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI. + - `aws.ec2.binaries_syncer.s3.key`: Object key of the runner distribution. + - `aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template. + - `aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates. + - `aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance. + - `aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption. + - `aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it. + - `aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume. + - `aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume. + - `aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it. + - `aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes. + - `aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB. + - `aws.ec2.block_device_mappings[].volume_type`: EBS volume type. + - `aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances. + - `aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`. + - `aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity. + - `aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type. + - `aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price. + - `aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions. + - `aws.ec2.user_data`: Runner bootstrap user-data configuration. + - `aws.ec2.user_data.enabled`: Enables launch-template user data. + - `aws.ec2.user_data.template`: Optional path to a custom user-data template. + - `aws.ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template. + - `aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template. + - `aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template. + - `aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs. + - `aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access. + - `aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role. + - `aws.ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances. + - `aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow. + - `aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`. + - `aws.ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group. + - `aws.ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults. + - `aws.ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing. + - `aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true. + - `aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent. + - `aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template. + - `aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file. + - `aws.ec2.key_name`: Optional EC2 key-pair name added to the launch template. + - `aws.ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group. + - `aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances. + - `aws.ec2.egress_rules`: Egress rules created on the managed runner security group. + - `aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations. + - `aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations. + - `aws.ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations. + - `aws.ec2.egress_rules[].from_port`: First destination port in the permitted range. + - `aws.ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols. + - `aws.ec2.egress_rules[].security_groups`: Destination security-group IDs. + - `aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true. + - `aws.ec2.egress_rules[].to_port`: Last destination port in the permitted range. + - `aws.ec2.egress_rules[].description`: Optional rule description. + - `aws.ec2.tags`: Additional tags for runner instances, EBS volumes, network interfaces, and eligible Spot instance requests created from the launch template. They override module-level tags and the generated runner `Name`; the provider-managed `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` bootstrap tags take final precedence. These tags do not apply to static provider resources such as the launch template, security group, IAM resources, SSM parameters, or log groups. + - `aws.ec2.metadata_options`: Instance Metadata Service configuration in the launch template. + - `aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`. + - `aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint. + - `aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required. + - `aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses. + - `aws.ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`. + - `aws.ec2.cpu_options`: CPU topology and processor-feature configuration. + - `aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance. + - `aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core. + - `aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types. + - `aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types. + - `aws.ec2.placement`: EC2 placement configuration for runner instances. + - `aws.ec2.placement.affinity`: Host affinity setting. + - `aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed. + - `aws.ec2.placement.group_id`: Placement-group ID. + - `aws.ec2.placement.group_name`: Placement-group name. + - `aws.ec2.placement.host_id`: Dedicated Host ID. + - `aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement. + - `aws.ec2.placement.spread_domain`: Spread-domain placement value. + - `aws.ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`. + - `aws.ec2.placement.partition_number`: Placement-group partition number. + - `aws.ec2.license_specifications`: License Manager configurations added to the launch template. + - `aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration. + - `aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces. + - `aws.ec2.on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure. + - `aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures. + - `aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. + EOT + + type = 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) + }), {}) + }) + +} diff --git a/modules/runner-config/variables.orchestration-provider.tf b/modules/runner-config/variables.orchestration-provider.tf new file mode 100644 index 0000000000..a5e6221954 --- /dev/null +++ b/modules/runner-config/variables.orchestration-provider.tf @@ -0,0 +1,172 @@ +# Typed orchestration-provider input boundary between the common runner configuration and demand controllers. +variable "orchestration_provider" { + description = <<-EOT + Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply. + + - `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. + - `scale_set`: Selects scale-set orchestration for this runner config. The multi-runner topology owns the shared controller service and passes this plan-known selection marker to runner-config. Scale-set runners always use ephemeral JIT registration. + - `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration. + - `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`. + - `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`. + - `webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. The default is null, which follows `runner.ephemeral`. + - `webhook.runner.maximum_count`: Maximum number of runners managed for this runner configuration. The default is `3`. + - `webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped. + - `webhook.queue.build.arn`: ARN of the runner configuration's build queue. + - `webhook.queue.build.url`: URL of the runner configuration's build queue. + - `webhook.queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. The default is null and is independent from the Parameter Store KMS key. + - `webhook.queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. The default is `{}`. + - `webhook.lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. Set at most one of `zip` or `s3`; no selection uses the packaged runner archive. + - `webhook.lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. The default is null. + - `webhook.lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning, selecting it requires a non-null common bucket, and the default is null. + - `webhook.lambda.artifact.s3.key`: Object key of the runner-control Lambda archive. + - `webhook.lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. The default is null. + - `webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. The default is `512`. + - `webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `60`. + - `webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency. + - `webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode. + - `webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`. + - `webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. The default is `0`. + - `webhook.lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. The default is `{}`. + - `webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. The default is `512`. + - `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. + - `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`. + - `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. + - `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`. + - `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. + - `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. + - `webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period. + - `webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`. + - `webhook.lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. The default is `{}`. + - `webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. The default is `512`. + - `webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`. + - `webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency. + - `webhook.lambda.pool.config`: Scheduled target pool sizes. The default is `[]`, which disables the pool component. + - `webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size. + - `webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule. + - `webhook.lambda.pool.config[].size`: Desired number of runners for the schedule. + - `webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`. + - `webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null. + - `webhook.lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags. The default is `{}`. + - `webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. The default is `false`. + - `webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. The default is `300`. + - `webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. The default is `2`. + - `webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. The default is `1`. + - `webhook.job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags. The default is `{}`. + - `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`. + - `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency. + - `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`. + - `scale_set`: Selects scale-set orchestration for this runner configuration. + - `scale_set.github.config_url`: GitHub Actions scale-set configuration URL. + - `scale_set.github.installation_id_ssm`: SSM parameter containing the GitHub App installation ID. + - `scale_set.name`: Name of the scale set. + - `scale_set.id`: Numeric scale-set ID. + - `scale_set.runner_group_id`: Optional GitHub runner-group ID. + - `scale_set.min_runners`: Minimum number of scale-set runners. The default is `0`. + - `scale_set.max_runners`: Maximum number of scale-set runners. The default is `10`. + - `scale_set.boot_time_in_minutes`: Expected scale-set runner boot duration. The default is `10`. + - `scale_set.session_owner`: Optional owner for scale-set runner sessions. + - `scale_set.work_folder`: Optional runner work folder. + EOT + type = 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) + }) + nullable = false + +} diff --git a/modules/runner-config/variables.tf b/modules/runner-config/variables.tf new file mode 100644 index 0000000000..82cd9eaead --- /dev/null +++ b/modules/runner-config/variables.tf @@ -0,0 +1,246 @@ +variable "aws_region" { + description = "AWS region." + type = string +} + +variable "aws_partition" { + description = "AWS partition used to construct ARNs." + type = string + default = "aws" +} + +variable "prefix" { + description = "The prefix used for naming resources." + type = string + default = "github-actions" +} + +variable "tags" { + description = "Base tags added to taggable resources created by this runner configuration. Shared, component, and compute-provider tag maps override matching keys within their documented resource scopes." + type = map(string) + default = {} +} + +variable "runner" { + description = <<-EOT + Provider-neutral GitHub runner configuration. + + - `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`. + - `architecture`: Runner distribution architecture, such as `x64` or `arm64`. + - `disable_default_labels`: Prevents GitHub's default self-hosted, operating-system, and architecture labels from being registered. + - `labels`: Complete set of labels supplied to the control-plane functions. + - `group_name`: GitHub runner group used during registration. + - `name_prefix`: Prefix added to registered runner names. + - `run_as_root`: Runs the runner service as root when supported by the compute provider. + - `run_as`: Operating-system user used when `run_as_root` is false. + - `auto_update_disabled`: Disables the GitHub runner application's built-in updater. + - `tags`: Additional tags for common runner resources, currently the managed runner IAM role. These override module-level `tags` with the same key. + - `hooks.job_started`: Script content installed as the runner job-started hook. + - `hooks.job_completed`: Script content installed as the runner job-completed hook. + - `iam.role.arn`: ARN of an externally managed runner role. When set, this module does not create or modify that role. + - `iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role. + - `iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy. + - `iam.path`: IAM path for the module-managed runner role. Defaults to a path derived from `prefix`. + - `iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role. + EOT + type = 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) + }), {}) + }) + +} + +variable "github" { + description = <<-EOT + GitHub API and runner-registration configuration. + + - `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. + - `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. + - `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. + - `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com. + - `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests. + - `user_agent`: Optional User-Agent value added to GitHub API requests. + EOT + type = 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) + }) +} + +variable "lambda" { + description = <<-EOT + Common Lambda substrate independent of the selected runner orchestration provider. + + - `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact. + - `runtime`: Runtime used by the control-plane Lambda functions. + - `architecture`: Instruction-set architecture used by the control-plane Lambda functions. Supported values are `arm64` and `x86_64`. + - `subnet_ids`: Subnets used for Lambda VPC configuration. + - `security_group_ids`: Security groups used for Lambda VPC configuration. + - `tags`: Shared tags applied to Lambda function resources only. These override module-level `tags`; component `tags` override this map when keys conflict. + - `principals`: Additional principals allowed to assume the control-plane Lambda roles. + - `role.path`: IAM path for module-managed Lambda execution roles. Defaults to a path derived from `prefix`. + - `role.permissions_boundary`: Permissions-boundary ARN applied to module-managed Lambda execution roles. + EOT + type = 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) + }), {}) + }) + default = {} + +} + +variable "ssm" { + description = <<-EOT + Parameter Store paths, encryption, tag scopes, and housekeeper configuration. + + - `paths.root`: Root Parameter Store path for this runner configuration. + - `paths.tokens`: Path segment under `paths.root` used for registration tokens and just-in-time configuration. + - `paths.config`: Path segment under `paths.root` used for persistent runner configuration. + - `kms_key_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters. + - `tags`: Shared tags for SSM-related resources. These override module-level `tags` and are inherited by parameter and housekeeper resources. + - `parameters.tags`: Tags for Terraform-managed runner configuration parameters and temporary parameters created by the scale-up and pool Lambdas. These override module-level and `ssm.tags` values with the same key. + - `housekeeper.schedule_expression`: EventBridge schedule expression that invokes the SSM housekeeper. + - `housekeeper.state`: EventBridge rule state, such as `ENABLED` or `DISABLED`. + - `housekeeper.tags`: Tags for housekeeper resources, including the Lambda function, log group, EventBridge rule, and IAM role. These override module-level, `ssm.tags`, shared Lambda, and shared log tags when keys conflict. + - `housekeeper.lambda.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact. + - `housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive. + - `housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket. + - `housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive. + - `housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive. + - `housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB. + - `housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds. + - `housekeeper.config.tokenPath`: Parameter Store token path cleaned by the housekeeper. When omitted, the configured runner token path is used. + - `housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed. + - `housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. + EOT + type = 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) + }), {}) + }), {}) + }) + +} + +variable "observability" { + description = <<-EOT + Logging, tracing, and metrics configuration for control-plane and provider resources. + + - `logs.level`: Application log level supplied to the control-plane functions. + - `logs.retention_in_days`: CloudWatch Logs retention period. + - `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt CloudWatch log groups. + - `logs.class`: CloudWatch log-group class. Supported values are `STANDARD` and `INFREQUENT_ACCESS`. + - `logs.tags`: Shared tags for CloudWatch log groups. These override module-level `tags`; component `tags` override this map when keys conflict. + - `tracing.mode`: Optional Lambda active-tracing mode. Null disables X-Ray tracing configuration. + - `tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper. + - `tracing.capture_error`: Enables error capture in the tracing helper. + - `metrics.enabled`: Enables module-emitted metrics. + - `metrics.namespace`: CloudWatch namespace used for emitted metrics. + - `metrics.metric.github_app_rate_limit.enabled`: Emits GitHub App rate-limit metrics. + - `metrics.metric.job_retry.enabled`: Emits job-retry metrics. + - `metrics.metric.spot_termination_warning.enabled`: Emits spot-termination warning metrics where supported. + EOT + type = object({ + logs = optional(object({ + level = optional(string, "info") + retention_in_days = optional(number, 180) + kms_key_id = optional(string, null) + class = optional(string, "STANDARD") + tags = optional(map(string), {}) + }), {}) + tracing = optional(object({ + mode = optional(string, null) + capture_http_requests = optional(bool, false) + capture_error = optional(bool, false) + }), {}) + metrics = optional(object({ + 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) + }), {}) + }), {}) + }), {}) + }) + default = {} + +} diff --git a/modules/runner-config/versions.tf b/modules/runner-config/versions.tf new file mode 100644 index 0000000000..3ef011ea0a --- /dev/null +++ b/modules/runner-config/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/runners/pool/variables.tf b/modules/runners/pool/variables.tf index 4c577e86c7..13404d7f57 100644 --- a/modules/runners/pool/variables.tf +++ b/modules/runners/pool/variables.tf @@ -92,5 +92,3 @@ variable "tracing_config" { }) default = {} } - - diff --git a/tests/ministack/README.md b/tests/ministack/README.md index b6442194ff..f0e6440fca 100644 --- a/tests/ministack/README.md +++ b/tests/ministack/README.md @@ -1,14 +1,16 @@ # MiniStack example tests The MiniStack workflow runs the `base`, `prebuilt`, `default`, `ephemeral`, -`multi-runner`, and `termination-watcher` examples directly with Terraform -1.4.0 and the latest Terraform release. +`multi-runner`, `multi-runner-v2`, `multi-runner-scale-set`, and +`termination-watcher` examples directly +with Terraform 1.4.0 and the latest Terraform release. The examples with input variables get their inputs from their own tfvars files in this directory. The `termination-watcher` example has no input variables and uses the configuration checked into the example itself. No override files, -setup module, or checked-in Terraform fixture configuration is used. The -helper creates and removes a temporary AMI override for `default` and -`ephemeral`, and temporary SSM parameters for `multi-runner`. +setup module, or Terraform fixture configuration is checked in. The helper +creates and removes a temporary AMI override for `default` and +`ephemeral`, temporary SSM parameters for `multi-runner`, and temporary AMI +fixtures plus an override for `multi-runner-v2` and `multi-runner-scale-set`. Start MiniStack, set the AWS endpoint and test credentials, then run: @@ -23,6 +25,10 @@ tests/ministack/run-example.sh apply ephemeral # or tests/ministack/run-example.sh apply multi-runner # or +tests/ministack/run-example.sh apply multi-runner-v2 +# or +tests/ministack/run-example.sh apply multi-runner-scale-set +# or tests/ministack/run-example.sh apply termination-watcher ``` @@ -31,5 +37,5 @@ ZIP fixtures in the paths expected by the modules when they are absent, and removes only the files it created. For `prebuilt`, it seeds AMI metadata through MiniStack's AWS-compatible EC2 API, then removes only the resources it created during cleanup. MiniStack v1.5.7 provides the EC2 image behavior needed by the -`default`, `ephemeral`, and `multi-runner` examples, so they are included in -the same lifecycle matrix. +`default`, `ephemeral`, `multi-runner`, and `multi-runner-scale-set` examples, +so they are included in the same lifecycle matrix. diff --git a/tests/ministack/multi-runner-scale-set.tfvars b/tests/ministack/multi-runner-scale-set.tfvars new file mode 100644 index 0000000000..c186656fad --- /dev/null +++ b/tests/ministack/multi-runner-scale-set.tfvars @@ -0,0 +1,51 @@ +environment = "ministack-scale-set" +aws_region = "eu-west-1" + +github_app = { + id = "0" + key_base64 = "ministack-invalid-key" +} + +runner_binaries_enabled = false + +ami = { + "linux-arm64" = { + filter = { + name = ["ministack-scale-set-linux-arm64"] + state = ["available"] + } + owners = ["self"] + } + "linux-x64" = { + filter = { + name = ["ministack-scale-set-linux-x64"] + state = ["available"] + } + owners = ["self"] + } + "linux-scale-set" = { + filter = { + name = ["ministack-scale-set-linux-x64"] + state = ["available"] + } + owners = ["self"] + } + "windows-x64" = { + filter = { + name = ["ministack-scale-set-windows-x64"] + state = ["available"] + } + owners = ["self"] + } +} + +scale_set = { + config_url = "https://github.com/example" + installation_id_ssm = { + name = "/ministack/scale-set/installation-id" + arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/scale-set/installation-id" + } + name = "ministack-scale-set" + id = 1 + runner_group_id = 1 +} diff --git a/tests/ministack/multi-runner-v2.tfvars b/tests/ministack/multi-runner-v2.tfvars new file mode 100644 index 0000000000..0bd3b7ac06 --- /dev/null +++ b/tests/ministack/multi-runner-v2.tfvars @@ -0,0 +1,33 @@ +environment = "ministack-multi-runner-v2" +aws_region = "eu-west-1" + +github_app = { + id = "0" + key_base64 = "ministack-invalid-key" +} + +runner_binaries_enabled = false + +ami = { + "linux-arm64" = { + filter = { + name = ["ministack-v2-linux-arm64"] + state = ["available"] + } + owners = ["self"] + } + "linux-x64" = { + filter = { + name = ["ministack-v2-linux-x64"] + state = ["available"] + } + owners = ["self"] + } + "windows-x64" = { + filter = { + name = ["ministack-v2-windows-x64"] + state = ["available"] + } + owners = ["self"] + } +} diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index e1a1b6a813..77b38d1e71 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -14,14 +14,14 @@ example="${2:-}" tfvars_file="${3:-${MINISTACK_TFVARS_FILE:-}}" case "$example" in - base | prebuilt | default | ephemeral | multi-runner) + base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | multi-runner-scale-set) use_tfvars=true ;; termination-watcher) use_tfvars=false ;; *) - echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, termination-watcher" >&2 + echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, multi-runner-scale-set, termination-watcher" >&2 exit 64 ;; esac @@ -29,7 +29,7 @@ esac case "$action" in init | plan | apply | destroy) ;; *) - echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|termination-watcher} [TFVARS_FILE]" >&2 + echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|multi-runner-scale-set|termination-watcher} [TFVARS_FILE]" >&2 exit 64 ;; esac @@ -245,6 +245,19 @@ $lambda_zip" "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64" \ "ami-0abcdef1234567890" ;; + multi-runner-v2) + create_ami_fixture "ministack-v2-linux-arm64" arm64 >/dev/null + create_ami_fixture "ministack-v2-linux-x64" x86_64 >/dev/null + create_ami_fixture "ministack-v2-windows-x64" x86_64 >/dev/null + ;; + multi-runner-scale-set) + create_ami_fixture "ministack-scale-set-linux-x64" x86_64 >/dev/null + create_ami_fixture "ministack-scale-set-linux-arm64" arm64 >/dev/null + create_ami_fixture "ministack-scale-set-windows-x64" x86_64 >/dev/null + create_ssm_fixture \ + "/ministack/scale-set/installation-id" \ + "1" + ;; esac }