From 1e9ccb2522f080ec903b1b94b0f15feeaf996f7c Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 17 Sep 2026 12:35:44 +0200 Subject: [PATCH 01/54] chore(microvm): refresh foundation provider locks --- .../microvm-foundation/.terraform.lock.hcl | 2 + .../.terraform.lock.hcl.tofu | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 examples/microvm-foundation/.terraform.lock.hcl.tofu diff --git a/examples/microvm-foundation/.terraform.lock.hcl b/examples/microvm-foundation/.terraform.lock.hcl index 2fc14d932b..78cf3de9bd 100644 --- a/examples/microvm-foundation/.terraform.lock.hcl +++ b/examples/microvm-foundation/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.63.0" constraints = ">= 6.61.0" hashes = [ + "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=", "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=", "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611", "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d", @@ -29,6 +30,7 @@ provider "registry.terraform.io/hashicorp/time" { version = "0.14.1" constraints = ">= 0.13.0" hashes = [ + "h1:GJig5pIwiKDsiF73KLs7vWvDs76/x6DeNSxKrfqlA40=", "h1:r93SxP++6gUlwCHDQ5OkRmcU8B0yv6ZA9nF0Dh6NJmA=", "zh:0837ca5b057e5cff94dff7de2fcccafb4abaa33c45de193fe2853e684818a267", "zh:15a122f72d9e0f34fc5384cc7ec089319641fee5c319748a3aa02fc42f459969", diff --git a/examples/microvm-foundation/.terraform.lock.hcl.tofu b/examples/microvm-foundation/.terraform.lock.hcl.tofu new file mode 100644 index 0000000000..045ca37e02 --- /dev/null +++ b/examples/microvm-foundation/.terraform.lock.hcl.tofu @@ -0,0 +1,76 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 6.61.0" + hashes = [ + "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", + "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", + "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", + "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", + "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", + "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", + "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", + "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", + "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", + "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", + "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", + "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", + "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", + "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", + "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", + "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", + "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", + "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", + "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", + "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", + "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", + "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", + "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", + "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", + "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", + "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", + "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", + "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", + "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", + "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", + ] +} + +provider "registry.opentofu.org/hashicorp/time" { + version = "0.14.1" + constraints = ">= 0.13.0" + hashes = [ + "h1:+anTsiSl8j75hcu7gKWF2ZlKS/qZDk4Ll0Oq2mVoArU=", + "h1:BZtorvSdYDM6pFE8nz7yUVVR2Pe1i0MxZFyBnaKlguY=", + "h1:Do/MjWRafefFS6RumnUVbIDH9MyMLuwQXs0kEc4Evrs=", + "h1:RoJeKHJjlqMikWdptWEWOBfBv4YxXf8KtZcg6oS8OWE=", + "h1:Ssb164oIHIO9VWGljof/xqbxbnPmrT5jyJ+WegAj2+k=", + "h1:WGgegEyoMb7nzXr09OvAxaSJls6honSdJiXfNaPTSkw=", + "h1:jfH6FAhiYd3hKB29s8cxk9PUKGdEN0fPB3zQ0IE5pdQ=", + "h1:lVTdvsa16YmLYJOmGq6ryESpdeWhLyo7Y4MAPPh/gIc=", + "h1:nD1nfDyZxI1PgTNT5Zs9G+R9PIiS25xQw9y7tuOD2G0=", + "h1:nPu3DGOZfwDier2k8DjNq2ZK3GQDzHYLghIUjb4/KJc=", + "h1:o7oRgk39V8okQzrI9DX4AKMTZrIqo6oBj1zQ3tMZJiI=", + "h1:oJSgnSkg9lXMISdrA+pXkV8FhgLPk+mAzbFE209jar0=", + "h1:oT/ffb2Uy19qRCLu9QSbuAguWwJI9rwW1j6RPjluqRY=", + "h1:vWWQpPBXFR9AVZM1o22/pHjNQjRa/IWSVL1mZgMqgLk=", + "h1:yTRqKp4efJyAq7bPZjFGcnR1KUDUl2/dBMzILxn5Z+c=", + "zh:032ea0f53759a5ade64286ad8a403956bb390860429de3647c6652701c2fff8b", + "zh:204581f170c50a579357b1a067f407b890adfc0404952cb922fefe2aba7655a8", + "zh:331119864191614a81ce9e8d1ec3ea6fe13da0bea6130f9e1dfd94e3b16ddaaa", + "zh:373a8b1b227a92b5e5fee611fef03df7aea82f51c3b1a62b33ac29a1b0ee927f", + "zh:46796e7616d511fa264a367ee447f6d0de64e8145f315a12271f4ec5c183f044", + "zh:5e3199e6dcc9bb99868764339c35aa169903baa4e150490da2980b2620cdfdbc", + "zh:5f95ee94a83a13e6b1e26d3d9f0297ed1036387d7a8adbab90b4ef990b6a1331", + "zh:68e1f75602423236d947d9464d62c4cc0a6312ff2206b9306067faf03bed7011", + "zh:72ee59f4f859abe6288b59eaacf9a838b8490a132c1c17f393dd8355401704df", + "zh:993775eeb0f0b4c898e305dead3cd6cd732b48c1c0fab20c2e4431ef6b678626", + "zh:a7b38fea85a1edea7f9afa33e9585970219a788d0300ca095e80b19eec39b291", + "zh:b7a8ef0dbee5b76295b1a11d88452ad2798e8d2598925af193884ce405093497", + "zh:d3ff8770f4b7cc4dccd4b1b7b40c933b468b7e07a05c704b7ea4c1673c74bf9c", + "zh:e59043c6f98aa986956a79c47f1d6bc150d409bbd9c88c9c42d41713bc539f04", + "zh:e6c2ae3bdccf6a2e3f106b61895cf7f125684204c1406b29f74fa408671f81cc", + ] +} From d6b397dc28d09f68f36e4278368ab8f000c1eaac Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 23:25:58 +0200 Subject: [PATCH 02/54] feat(microvm): add image build example --- .github/workflows/packer-build.yml | 2 +- .github/workflows/terraform.yml | 3 +- docs/examples/microvm.md | 3 + examples/microvm-foundation/README.md | 2 +- examples/microvm/.terraform.lock.hcl | 68 ++ examples/microvm/README.md | 102 +++ examples/microvm/main.tf | 110 ++++ examples/microvm/outputs.tf | 9 + examples/microvm/providers.tf | 9 + examples/microvm/variables.tf | 95 +++ examples/microvm/versions.tf | 9 + images/README.md | 13 + images/microvm-ubuntu/README.md | 46 ++ .../github_agent.microvm.ubuntu.pkr.hcl | 118 ++++ .../scripts/microvm/build-microvm-image.py | 583 ++++++++++++++++++ .../scripts/microvm/image/.dockerignore | 2 + .../scripts/microvm/image/image-entrypoint.sh | 22 + .../image/services/cloudwatch-agent.sh | 49 ++ .../scripts/microvm/image/start-services.sh | 39 ++ .../microvm/image/ubuntu24.arm64.Dockerfile | 174 ++++++ modules/microvm-foundation/README.md | 2 +- 21 files changed, 1456 insertions(+), 4 deletions(-) create mode 100644 docs/examples/microvm.md create mode 100644 examples/microvm/.terraform.lock.hcl create mode 100644 examples/microvm/README.md create mode 100644 examples/microvm/main.tf create mode 100644 examples/microvm/outputs.tf create mode 100644 examples/microvm/providers.tf create mode 100644 examples/microvm/variables.tf create mode 100644 examples/microvm/versions.tf create mode 100644 images/microvm-ubuntu/README.md create mode 100644 images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/.dockerignore create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/image-entrypoint.sh create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/services/cloudwatch-agent.sh create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/start-services.sh create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/ubuntu24.arm64.Dockerfile diff --git a/.github/workflows/packer-build.yml b/.github/workflows/packer-build.yml index 8dcff4efb6..726d70e9d3 100644 --- a/.github/workflows/packer-build.yml +++ b/.github/workflows/packer-build.yml @@ -28,7 +28,7 @@ jobs: image: index.docker.io/hashicorp/packer@sha256:12c441b8a3994e7df9f0e2692d9298f14c387e70bcc06139420977dbf80a137b # 1.11.2 strategy: matrix: - image: ["linux-al2023", "windows-core-2019", "windows-core-2022", "ubuntu-focal", "ubuntu-jammy", "ubuntu-jammy-arm64"] + image: ["linux-al2023", "windows-core-2019", "windows-core-2022", "ubuntu-focal", "ubuntu-jammy", "ubuntu-jammy-arm64", "microvm-ubuntu"] defaults: run: working-directory: images/${{ matrix.image }} diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 30bdb75783..4e9a445123 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -234,7 +234,8 @@ jobs: "multi-runner", "multi-runner-v2", "external-managed-ssm-secrets", - "microvm-foundation" + "microvm-foundation", + "microvm" ] defaults: run: diff --git a/docs/examples/microvm.md b/docs/examples/microvm.md new file mode 100644 index 0000000000..4014781114 --- /dev/null +++ b/docs/examples/microvm.md @@ -0,0 +1,3 @@ +# Lambda MicroVM + +--8<-- "examples/microvm/README.md" diff --git a/examples/microvm-foundation/README.md b/examples/microvm-foundation/README.md index aa54e94fa0..841552abd5 100644 --- a/examples/microvm-foundation/README.md +++ b/examples/microvm-foundation/README.md @@ -14,7 +14,7 @@ terraform output ``` Apply this foundation before building an image with the direct Packer commands -documented in `../../images/microvm/README.md`. Use the outputs as the build inputs: +documented in `../../images/microvm-ubuntu/README.md`. Use the outputs as the build inputs: - `artifact_bucket_name` -> `MICROVM_ARTIFACT_BUCKET` - `build_role_arn` -> `MICROVM_BUILD_ROLE_ARN` diff --git a/examples/microvm/.terraform.lock.hcl b/examples/microvm/.terraform.lock.hcl new file mode 100644 index 0000000000..7a131aab93 --- /dev/null +++ b/examples/microvm/.terraform.lock.hcl @@ -0,0 +1,68 @@ +# 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: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: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=", + "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/microvm/README.md b/examples/microvm/README.md new file mode 100644 index 0000000000..514c790110 --- /dev/null +++ b/examples/microvm/README.md @@ -0,0 +1,102 @@ +# Lambda MicroVM runner example + +This example creates the VPC and GitHub Actions runner control plane for one +Linux ARM64 Lambda MicroVM lane. The lane uses ephemeral runners and +just-in-time configuration, which are required by the MicroVM provider. + +The regional MicroVM foundation is provisioned separately by the +[`microvm-foundation`](../microvm-foundation) example. Apply that example +first and provide its artifact bucket, build role, and egress Network Connector +outputs to the image build script. The image ARN produced by that build is then +supplied to this example. + +The GitHub App credentials must already exist in SSM Parameter Store. The +example outputs the webhook endpoint; configure that endpoint on the GitHub +App with the same secret stored in the referenced SSM parameter. + +## Usage + +Build or download the Lambda archives into an S3 bucket, then create a +`terraform.tfvars` file. The parameter references below are examples only: + +```hcl +aws_region = "eu-west-1" +lambda_artifact_bucket = "my-runner-lambda-artifacts" +microvm_image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:github-runner-arm64" +egress_network_connector_arn = "arn:aws:lambda:eu-west-1:123456789012:network-connector:example" + +github_app = { + key_base64_ssm = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-key" + name = "/github-runner/app-key" + } + id_ssm = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + name = "/github-runner/app-id" + } + webhook_secret_ssm = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/webhook-secret" + name = "/github-runner/webhook-secret" + } +} +``` + +Run Terraform from this directory: + +```bash +terraform init +terraform apply +terraform output -raw webhook_endpoint +``` + +The MicroVM image must be built for Linux ARM64 and should use a versioned image +ARN in production. Network connector egress remains bounded by the VPC route +tables and network ACLs configured by the helper module. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [base](#module\_base) | ../base | n/a | +| [runners](#module\_runners) | ../../modules/multi-runner | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_region](#input\_aws\_region) | AWS Region where the runner control plane and MicroVM resources are deployed. | `string` | `"eu-west-1"` | no | +| [egress\_network\_connector\_arn](#input\_egress\_network\_connector\_arn) | Regional Lambda Network Connector ARN used by MicroVMs and the image build. | `string` | n/a | yes | +| [environment](#input\_environment) | Name prefix for the example resources. | `string` | `null` | no | +| [github\_app](#input\_github\_app) | Pre-created SSM parameter references for the GitHub App credentials. |
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 | +| [ingress\_network\_connector\_arns](#input\_ingress\_network\_connector\_arns) | Optional regional Lambda Network Connector ARNs exposed to MicroVMs. | `list(string)` | `[]` | no | +| [lambda\_artifact\_bucket](#input\_lambda\_artifact\_bucket) | S3 bucket containing the runner-control Lambda artifacts. | `string` | n/a | yes | +| [microvm\_image\_arn](#input\_microvm\_image\_arn) | Lambda MicroVM image ARN produced by the MicroVM image build. | `string` | n/a | yes | +| [microvm\_image\_version](#input\_microvm\_image\_version) | Optional immutable version of the Lambda MicroVM image. | `string` | `null` | no | +| [organization\_runners](#input\_organization\_runners) | Register the MicroVM runners at organization scope when true. | `bool` | `false` | no | +| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for the runners Lambda archive. | `string` | `"runners.zip"` | no | +| [runners\_maximum\_count](#input\_runners\_maximum\_count) | Maximum number of concurrent MicroVM runners. | `number` | `10` | no | +| [webhook\_lambda\_s3\_key](#input\_webhook\_lambda\_s3\_key) | S3 key for the webhook Lambda archive. | `string` | `"webhook.zip"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [microvm\_image\_arn](#output\_microvm\_image\_arn) | The MicroVM image ARN consumed by this runner configuration. | +| [webhook\_endpoint](#output\_webhook\_endpoint) | Webhook endpoint to configure on the GitHub App. | + diff --git a/examples/microvm/main.tf b/examples/microvm/main.tf new file mode 100644 index 0000000000..e3b2aaa6b4 --- /dev/null +++ b/examples/microvm/main.tf @@ -0,0 +1,110 @@ +locals { + environment = coalesce(var.environment, "microvm") + aws_region = var.aws_region +} + +module "base" { + source = "../base" + + prefix = local.environment + aws_region = local.aws_region +} + +module "runners" { + source = "../../modules/multi-runner" + + aws_region = local.aws_region + vpc_id = module.base.vpc.vpc_id + subnet_ids = module.base.vpc.private_subnets + prefix = local.environment + + # Required for backwards-compatible module input validation; the non-empty + # experimental map selects the MicroVM configuration below. + multi_runner_config = {} + + # Keep GitHub App credentials in pre-created SSM parameters. This example + # therefore does not place the private key or webhook secret in Terraform + # configuration or state. + github_app = var.github_app + + experimental_global_config_github = { + app = var.github_app + } + + experimental_global_config_lambda = { + artifact = { + s3 = { + bucket = var.lambda_artifact_bucket + } + } + } + + experimental_global_config_orchestration_provider = { + webhook = { + runner = { + ephemeral = true + jit_config_enabled = true + maximum_count = var.runners_maximum_count + boot_time_in_minutes = 5 + } + github = { + organization_runners = var.organization_runners + } + lambda = { + artifact = { + s3 = { + key = var.runners_lambda_s3_key + } + } + webhook = { + artifact = { + s3 = { + key = var.webhook_lambda_s3_key + } + } + } + } + } + } + + experimental_global_config_ssm = { + paths = { + root = "/github-action-runners/${local.environment}" + } + } + + experimental_global_config_compute_provider = { + aws = { + microvm = { + image_arn = var.microvm_image_arn + image_version = var.microvm_image_version + ingress_network_connectors = var.ingress_network_connector_arns + egress_network_connectors = [var.egress_network_connector_arn] + } + } + } + + experimental_multi_runner_config = { + microvm = { + runner = { + os = "linux" + architecture = "arm64" + name_prefix = "microvm-" + extra_labels = ["microvm"] + } + orchestration_provider = { + webhook = { + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "arm64", "microvm"]] + bidirectionalLabelMatch = true + } + } + } + compute_provider = { + aws = { + microvm = {} + } + } + } + } +} diff --git a/examples/microvm/outputs.tf b/examples/microvm/outputs.tf new file mode 100644 index 0000000000..87ad4c924c --- /dev/null +++ b/examples/microvm/outputs.tf @@ -0,0 +1,9 @@ +output "webhook_endpoint" { + description = "Webhook endpoint to configure on the GitHub App." + value = module.runners.webhook.endpoint +} + +output "microvm_image_arn" { + description = "The MicroVM image ARN consumed by this runner configuration." + value = var.microvm_image_arn +} diff --git a/examples/microvm/providers.tf b/examples/microvm/providers.tf new file mode 100644 index 0000000000..eca2fe96a7 --- /dev/null +++ b/examples/microvm/providers.tf @@ -0,0 +1,9 @@ +provider "aws" { + region = local.aws_region + + default_tags { + tags = { + Example = local.environment + } + } +} diff --git a/examples/microvm/variables.tf b/examples/microvm/variables.tf new file mode 100644 index 0000000000..7a6f1abd61 --- /dev/null +++ b/examples/microvm/variables.tf @@ -0,0 +1,95 @@ +variable "aws_region" { + description = "AWS Region where the runner control plane and MicroVM resources are deployed." + type = string + default = "eu-west-1" +} + +variable "environment" { + description = "Name prefix for the example resources." + type = string + default = null +} + +variable "github_app" { + description = "Pre-created SSM parameter references for the GitHub App credentials." + type = 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 + })) + }) + + validation { + condition = ( + var.github_app.key_base64 == null && + var.github_app.id == null && + var.github_app.webhook_secret == null && + var.github_app.key_base64_ssm != null && + var.github_app.id_ssm != null && + var.github_app.webhook_secret_ssm != null + ) + error_message = "github_app must use pre-created SSM parameters for the key, app ID, and webhook secret." + } +} + +variable "lambda_artifact_bucket" { + description = "S3 bucket containing the runner-control Lambda artifacts." + type = string +} + +variable "runners_lambda_s3_key" { + description = "S3 key for the runners Lambda archive." + type = string + default = "runners.zip" +} + +variable "webhook_lambda_s3_key" { + description = "S3 key for the webhook Lambda archive." + type = string + default = "webhook.zip" +} + +variable "microvm_image_arn" { + description = "Lambda MicroVM image ARN produced by the MicroVM image build." + type = string +} + +variable "microvm_image_version" { + description = "Optional immutable version of the Lambda MicroVM image." + type = string + default = null +} + +variable "egress_network_connector_arn" { + description = "Regional Lambda Network Connector ARN used by MicroVMs and the image build." + type = string +} + +variable "ingress_network_connector_arns" { + description = "Optional regional Lambda Network Connector ARNs exposed to MicroVMs." + type = list(string) + default = [] +} + +variable "organization_runners" { + description = "Register the MicroVM runners at organization scope when true." + type = bool + default = false +} + +variable "runners_maximum_count" { + description = "Maximum number of concurrent MicroVM runners." + type = number + default = 10 +} diff --git a/examples/microvm/versions.tf b/examples/microvm/versions.tf new file mode 100644 index 0000000000..e4d4e1e015 --- /dev/null +++ b/examples/microvm/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } + required_version = ">= 1.3.0" +} diff --git a/images/README.md b/images/README.md index 689f3e2df5..c6722c2c2f 100644 --- a/images/README.md +++ b/images/README.md @@ -39,3 +39,16 @@ ami_owners = [""] enable_userdata = false ``` + +## Lambda MicroVM images + +The `microvm-ubuntu` directory contains the Packer inputs for the Lambda MicroVM +image workflow. Unlike the AMI examples above, Lambda owns the image build. +The Packer template, Dockerfile, lifecycle-hook ZIP contract, and image +entrypoint are under `microvm-ubuntu/`; the compiled hook server is supplied +separately as a build artifact. + +Apply [`examples/microvm-foundation`](../examples/microvm-foundation) first, +then follow the [`microvm-ubuntu` build instructions](microvm-ubuntu/README.md) and run +Packer with its outputs. Use the resulting image ARN in the +[`examples/microvm`](../examples/microvm) runner example. diff --git a/images/microvm-ubuntu/README.md b/images/microvm-ubuntu/README.md new file mode 100644 index 0000000000..84feeb24b4 --- /dev/null +++ b/images/microvm-ubuntu/README.md @@ -0,0 +1,46 @@ +# Lambda MicroVM image build + +This directory contains the complete Lambda MicroVM image build inputs adapted +from the companion base-image repository: the Packer template, pinned ARM64 +Dockerfile, compiled lifecycle-hook ZIP contract, and image entrypoint. + +Before building the image: + +1. Apply `examples/microvm-foundation` in the target AWS Region. +2. Install Packer and set the required AWS, S3, IAM, connector, and + lifecycle-hook variables. + +The image intentionally excludes the source repository's optional external +telemetry and Teleport services. It contains only the Actions runner, +CloudWatch Agent, and lifecycle-hook server; no credentials are stored in the +image source. + +The `github_agent.microvm.ubuntu.pkr.hcl` template packages a deterministic +artifact, resolves the Ubuntu ECR mirror to a digest, uploads the artifact to +the regional S3 bucket, and waits for the Lambda MicroVM image version to +become active. + +```bash +export AWS_REGION="" +export AWS_DATA_PATH="" +export MICROVM_ARTIFACT_BUCKET="" +export MICROVM_BUILD_ROLE_ARN="" +export MICROVM_EGRESS_NETWORK_CONNECTOR_ARN="" +export MICROVM_IMAGE_NAME="" +export MICROVM_LIFECYCLE_HOOK_ZIP="" +export MICROVM_LOG_GROUP="" +export MICROVM_MEMORY_MIB=8192 +export MICROVM_UBUNTU_IMAGE="" +export MICROVM_IDEMPOTENCY_NONCE="$(date -u +%Y%m%dT%H%M%SZ)" + +packer init . +packer fmt -check=true github_agent.microvm.ubuntu.pkr.hcl +packer validate -evaluate-datasources github_agent.microvm.ubuntu.pkr.hcl +packer build -color=false github_agent.microvm.ubuntu.pkr.hcl +``` + +The build role, artifact bucket, and network connector are created by the +foundation module. Keep the bucket private and versioned, use the module's +least-privilege policies, and do not put credentials in checked-in files. The +lifecycle-hook ZIP must contain the compiled `server.js` at its archive root; +any bundled dependencies must use safe relative paths. diff --git a/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl b/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl new file mode 100644 index 0000000000..f8d0638d94 --- /dev/null +++ b/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl @@ -0,0 +1,118 @@ +# Lambda, rather than Packer, owns the MicroVM image build. This single +# pseudo-Packer target provides the same build interface as the AMI pipelines +# while delegating packaging, regional publication, and polling to boto3. +# The null builder and shell-local provisioner are Packer built-ins, so this +# template intentionally has no required_plugins entry for them. + +variable "aws_data_path" { + description = "Botocore data path containing the Lambda MicroVM service model." + type = string + default = env("AWS_DATA_PATH") +} + +variable "aws_region" { + description = "AWS Region for the S3 artifact, Ubuntu ECR mirror, and Lambda MicroVM image." + type = string + default = env("AWS_REGION") +} + +variable "artifact_bucket" { + description = "S3 artifact bucket. Lambda MicroVMs requires this bucket to be in aws_region." + type = string + default = env("MICROVM_ARTIFACT_BUCKET") +} + +variable "build_role_arn" { + description = "IAM role assumed by Lambda while it builds the MicroVM image." + type = string + default = env("MICROVM_BUILD_ROLE_ARN") +} + +variable "egress_network_connector_arn" { + description = "ARN of the regional Lambda Network Connector used for image-build egress." + type = string + default = env("MICROVM_EGRESS_NETWORK_CONNECTOR_ARN") +} + +variable "image_name" { + description = "Name of the customer Lambda MicroVM image." + type = string + default = env("MICROVM_IMAGE_NAME") +} + +variable "idempotency_nonce" { + description = "Per-attempt nonce that permits a workflow rerun to replace an asynchronously failed build." + type = string + default = env("MICROVM_IDEMPOTENCY_NONCE") +} + +variable "lifecycle_hook_zip" { + description = "ZIP containing the compiled lifecycle-hook server.js at the archive root." + type = string + default = env("MICROVM_LIFECYCLE_HOOK_ZIP") +} + +variable "log_group" { + description = "CloudWatch Logs group for the Lambda MicroVM image build." + type = string + default = env("MICROVM_LOG_GROUP") +} + +variable "memory_mib" { + description = "MicroVM memory tier in MiB. The complete runner image currently requires the 8192 MiB tier's 32 GiB disk." + type = string + default = env("MICROVM_MEMORY_MIB") +} + +variable "output_dir" { + description = "Directory for deterministic build artifacts and publication manifests." + type = string + default = env("MICROVM_OUTPUT_DIR") +} + +variable "release_version" { + description = "Stable or prerelease version recorded in MicroVM metadata." + type = string + default = env("MICROVM_RELEASE_VERSION") +} + +variable "ubuntu_image" { + description = "Regional private ECR mirror used for the Ubuntu 24.04 Dockerfile stages." + type = string + default = env("MICROVM_UBUNTU_IMAGE") +} + +source "null" "lambda_microvm" { + communicator = "none" +} + +build { + name = "lambda-microvm-image" + sources = [ + "source.null.lambda_microvm" + ] + + provisioner "shell-local" { + # MICROVM_ENVIRONMENT_VARIABLES is inherited from the build step. Do not + # add it here: shell-local renders environment_vars into the shell argv. + environment_vars = [ + "AWS_DATA_PATH=${var.aws_data_path}", + "AWS_REGION=${var.aws_region}", + "MICROVM_ARTIFACT_BUCKET=${var.artifact_bucket}", + "MICROVM_BUILD_ROLE_ARN=${var.build_role_arn}", + "MICROVM_EGRESS_NETWORK_CONNECTOR_ARN=${var.egress_network_connector_arn}", + "MICROVM_IMAGE_NAME=${var.image_name}", + "MICROVM_IDEMPOTENCY_NONCE=${var.idempotency_nonce}", + "MICROVM_LIFECYCLE_HOOK_ZIP=${var.lifecycle_hook_zip}", + "MICROVM_LOG_GROUP=${var.log_group}", + "MICROVM_MEMORY_MIB=${var.memory_mib}", + "MICROVM_OUTPUT_DIR=${var.output_dir}", + "MICROVM_RELEASE_VERSION=${var.release_version}", + "MICROVM_UBUNTU_IMAGE=${var.ubuntu_image}", + "PYTHONDONTWRITEBYTECODE=1", + "PYTHONUNBUFFERED=1", + ] + script = "packer/scripts/microvm/build-microvm-image.py" + timeout = "90m" + } +} diff --git a/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py b/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py new file mode 100644 index 0000000000..81c13beb2e --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +"""Package and publish the ARM64 Lambda MicroVM runner image.""" + +from __future__ import annotations + +import base64 +import datetime as dt +import hashlib +import json +import os +import re +import stat +import subprocess +import sys +import tempfile +import time +import zipfile +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +from pathlib import PurePosixPath +from typing import Any, Iterable, Mapping + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +IMAGE_ROOT = Path(__file__).resolve().parent / 'image' +OUTPUT_ROOT = REPOSITORY_ROOT / 'output' / 'microvm' +DOCKERFILE = 'ubuntu24.arm64.Dockerfile' +ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0) +WAIT_TIMEOUT_SECONDS = 3000 +EXCLUDED_DIRECTORIES = { + '.cache', + '.git', + '.mypy_cache', + '.pytest_cache', + '.ruff_cache', + '__pycache__', + 'dist', + 'node_modules', +} +EXCLUDED_FILES = {'.DS_Store', '.git'} + + +class BuildError(RuntimeError): + """Expected publication failure.""" + + +@dataclass(frozen=True) +class Settings: + region: str + artifact_bucket: str + build_role_arn: str + egress_network_connector_arn: str + environment_variables: Mapping[str, str] + image_name: str + idempotency_nonce: str + lifecycle_hook_zip: Path + log_group: str + memory_mib: int + output_dir: Path + release_version: str + ubuntu_image: str + + +@dataclass(frozen=True) +class Artifact: + path: Path + sha256: str + + +def environment(name: str, default: str = '') -> str: + return os.environ.get(name, '').strip() or default + + +def load_settings() -> Settings: + return Settings( + region=environment('AWS_REGION'), + artifact_bucket=environment('MICROVM_ARTIFACT_BUCKET'), + build_role_arn=environment('MICROVM_BUILD_ROLE_ARN'), + egress_network_connector_arn=environment( + 'MICROVM_EGRESS_NETWORK_CONNECTOR_ARN' + ), + environment_variables=json.loads( + environment('MICROVM_ENVIRONMENT_VARIABLES', '{}') + ), + image_name=environment('MICROVM_IMAGE_NAME'), + idempotency_nonce=environment('MICROVM_IDEMPOTENCY_NONCE'), + lifecycle_hook_zip=Path( + environment('MICROVM_LIFECYCLE_HOOK_ZIP') + ).resolve(), + log_group=environment('MICROVM_LOG_GROUP'), + memory_mib=int(environment('MICROVM_MEMORY_MIB')), + output_dir=Path( + environment('MICROVM_OUTPUT_DIR', str(OUTPUT_ROOT)) + ).resolve(), + release_version=environment('MICROVM_RELEASE_VERSION'), + ubuntu_image=environment('MICROVM_UBUNTU_IMAGE'), + ) + + +def artifact_files(root: Path) -> Iterable[Path]: + for current_root, directories, files in os.walk(root): + directories[:] = sorted( + name for name in directories if name not in EXCLUDED_DIRECTORIES + ) + current = Path(current_root) + for name in sorted(files): + path = current / name + if all( + ( + name not in EXCLUDED_FILES, + path.suffix not in {'.pyc', '.pyo'}, + path.is_file(), + not path.is_symlink(), + ) + ): + yield path + + +def render_dockerfile(contents: bytes, ubuntu_image: str) -> bytes: + rendered = re.sub( + r'^ARG UBUNTU_IMAGE(?:=.*)?$', + f"ARG UBUNTU_IMAGE={json.dumps(ubuntu_image)}", + contents.decode(), + flags=re.MULTILINE, + ) + return rendered.encode() + + +def validate_lifecycle_hook_zip(path: Path) -> None: + if not path.is_file(): + raise BuildError( + f'MICROVM_LIFECYCLE_HOOK_ZIP must point to a file: {path}' + ) + + try: + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + except (OSError, zipfile.BadZipFile) as error: + raise BuildError( + f'MICROVM_LIFECYCLE_HOOK_ZIP is not a valid ZIP archive: {path}' + ) from error + + files = set() + for member in members: + member_path = PurePosixPath(member.filename) + if member_path.is_absolute() or '..' in member_path.parts: + raise BuildError( + 'MICROVM_LIFECYCLE_HOOK_ZIP contains an unsafe archive path: ' + f'{member.filename}' + ) + if stat.S_IFMT(member.external_attr >> 16) == stat.S_IFLNK: + raise BuildError( + 'MICROVM_LIFECYCLE_HOOK_ZIP must not contain symbolic links: ' + f'{member.filename}' + ) + if not member.filename.endswith('/'): + files.add(member.filename) + + if 'server.js' not in files: + raise BuildError( + 'MICROVM_LIFECYCLE_HOOK_ZIP must contain a compiled server.js ' + 'at the archive root' + ) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as file_handle: + for chunk in iter(lambda: file_handle.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def create_artifact(settings: Settings, ubuntu_image: str) -> Artifact: + validate_lifecycle_hook_zip(settings.lifecycle_hook_zip) + files = [ + ( + 'Dockerfile' + if path == IMAGE_ROOT / DOCKERFILE + else path.relative_to(IMAGE_ROOT).as_posix(), + path, + ) + for path in artifact_files(IMAGE_ROOT) + ] + files.append(('lifecycle-hook.zip', settings.lifecycle_hook_zip)) + files.sort(key=lambda item: item[0]) + settings.output_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory( + prefix='microvm-package-', dir=settings.output_dir + ) as temporary: + temporary_zip = Path(temporary) / 'microvm-image.zip' + with zipfile.ZipFile( + temporary_zip, + mode='w', + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + for archive_name, source in files: + contents = source.read_bytes() + if archive_name == 'Dockerfile': + contents = render_dockerfile(contents, ubuntu_image) + mode = 0o755 if source.stat().st_mode & 0o111 else 0o644 + info = zipfile.ZipInfo(archive_name, ZIP_TIMESTAMP) + info.create_system = 3 + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = (stat.S_IFREG | mode) << 16 + archive.writestr( + info, + contents, + compress_type=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) + + digest = sha256_file(temporary_zip) + artifact_path = settings.output_dir / ( + f"{settings.image_name}-{digest[:12]}.zip" + ) + os.replace(temporary_zip, artifact_path) + return Artifact(artifact_path, digest) + + +def source_revision() -> str: + revision = environment('GITHUB_SHA') or environment('SOURCE_REVISION') + if revision: + return revision[:12] + completed = subprocess.run( + [ + 'git', + '-C', + str(REPOSITORY_ROOT), + 'rev-parse', + '--short=12', + 'HEAD', + ], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def aws_session(region: str) -> Any: + try: + import boto3 # type: ignore[import-not-found] + except ModuleNotFoundError as error: + raise BuildError( + 'boto3 is required to publish the MicroVM image' + ) from error + return boto3.Session(region_name=region) + + +def microvm_client(session: Any, region: str) -> Any: + try: + return session.client('lambda-microvms', region_name=region) + except Exception as error: + if type(error).__name__ == 'UnknownServiceError': + raise BuildError( + 'AWS_DATA_PATH must contain the Lambda MicroVM service model' + ) from error + raise + + +def resolve_ubuntu_image(ecr: Any, image: str) -> str: + if '@' in image: + return image + repository_uri, tag = image.rsplit(':', 1) + registry, repository = repository_uri.split('/', 1) + account = registry.split('.', 1)[0] + response = ecr.describe_images( + registryId=account, + repositoryName=repository, + imageIds=[{'imageTag': tag}], + ) + digest = response['imageDetails'][0]['imageDigest'] + return f"{repository_uri}@{digest}" + + +def upload_artifact( + s3: Any, settings: Settings, artifact: Artifact, revision: str +) -> str: + key = f"lambda-microvms/artifacts/{artifact.sha256}.zip" + checksum = base64.b64encode(bytes.fromhex(artifact.sha256)).decode() + with artifact.path.open('rb') as file_handle: + s3.put_object( + Bucket=settings.artifact_bucket, + Key=key, + Body=file_handle, + ChecksumSHA256=checksum, + ContentType='application/zip', + Metadata={ + 'sha256': artifact.sha256, + 'source-revision': revision, + }, + ) + return f"s3://{settings.artifact_bucket}/{key}" + + +def find_image(client: Any, name: str) -> str: + request: dict[str, Any] = {'maxResults': 50, 'nameFilter': name} + while True: + response = client.list_microvm_images(**request) + for image in response.get('items', []): + if image.get('name') == name: + return str(image['imageArn']) + token = response.get('nextToken') + if not token: + return '' + request['nextToken'] = token + + +def log_stream(settings: Settings) -> str: + if settings.idempotency_nonce: + return f"{settings.image_name}/{settings.idempotency_nonce}" + return settings.image_name + + +def build_request( + settings: Settings, + artifact_uri: str, + revision: str, + image_arn: str, +) -> dict[str, Any]: + operation = 'update' if image_arn else 'create' + description = f"Ephemeral GitHub Actions runner from {revision}" + if settings.release_version: + description = ( + f"Ephemeral GitHub Actions runner release " + f"{settings.release_version} from {revision}" + ) + request: dict[str, Any] = { + 'additionalOsCapabilities': ['ALL'], + 'baseImageArn': ( + f"arn:aws:lambda:{settings.region}:aws:microvm-image:al2023-1" + ), + 'buildRoleArn': settings.build_role_arn, + 'codeArtifact': {'uri': artifact_uri}, + 'cpuConfigurations': [{'architecture': 'ARM_64'}], + 'description': description, + 'egressNetworkConnectors': [settings.egress_network_connector_arn], + 'environmentVariables': dict(settings.environment_variables), + 'hooks': { + 'port': 8080, + 'microvmHooks': { + 'run': 'ENABLED', + 'runTimeoutInSeconds': 60, + 'terminate': 'ENABLED', + 'terminateTimeoutInSeconds': 60, + }, + 'microvmImageHooks': { + 'ready': 'ENABLED', + 'readyTimeoutInSeconds': 120, + 'validate': 'ENABLED', + 'validateTimeoutInSeconds': 120, + }, + }, + 'logging': { + 'cloudWatch': { + 'logGroup': settings.log_group, + 'logStream': log_stream(settings), + } + }, + 'resources': [{'minimumMemoryInMiB': settings.memory_mib}], + } + if operation == 'create': + request['name'] = settings.image_name + else: + request['imageIdentifier'] = image_arn + + canonical = json.dumps(request, sort_keys=True, separators=(',', ':')) + request['clientToken'] = hashlib.sha256( + ( + f"{settings.region}|{operation}|{settings.idempotency_nonce}|" + f"{canonical}" + ).encode() + ).hexdigest() + return request + + +def start_build(client: Any, request: Mapping[str, Any]) -> dict[str, Any]: + if 'imageIdentifier' in request: + print('Starting Lambda MicroVM image update') + return client.update_microvm_image(**request) + print('Starting Lambda MicroVM image create') + return client.create_microvm_image(**request) + + +def wait_for_image( + client: Any, image_arn: str, image_version: str +) -> tuple[dict[str, Any], dict[str, Any]]: + deadline = time.monotonic() + WAIT_TIMEOUT_SECONDS + last_state: tuple[str, str, str] | None = None + while time.monotonic() < deadline: + try: + version = client.get_microvm_image_version( + imageIdentifier=image_arn, + imageVersion=image_version, + ) + except Exception as error: + response = getattr(error, 'response', {}) + error_code = response.get('Error', {}).get('Code') + if error_code == 'ResourceNotFoundException': + time.sleep(10) + continue + raise + + state = str(version.get('state', 'UNKNOWN')) + status = str(version.get('status', 'UNKNOWN')) + image: dict[str, Any] = {} + image_state = 'UNKNOWN' + if state == 'SUCCESSFUL': + image = client.get_microvm_image(imageIdentifier=image_arn) + image_state = str(image.get('state', 'UNKNOWN')) + observed = (state, status, image_state) + if observed != last_state: + print( + f"MicroVM image version {image_version}: state={state} " + f"status={status} image_state={image_state}" + ) + last_state = observed + if state == 'FAILED': + raise BuildError( + 'MicroVM image build failed: ' + f"{version.get('stateReason', 'no reason returned')}" + ) + if state == 'SUCCESSFUL' and status == 'ACTIVE' and image_state in { + 'CREATED', + 'UPDATED', + }: + return image, version + time.sleep(10) + raise BuildError( + f"timed out waiting for MicroVM image after " + f"{WAIT_TIMEOUT_SECONDS} seconds" + ) + + +def print_build_logs( + logs: Any, settings: Settings, start_time_ms: int +) -> None: + request: dict[str, Any] = { + 'logGroupName': settings.log_group, + 'logStreamNames': [log_stream(settings)], + 'startTime': start_time_ms, + } + while True: + response = logs.filter_log_events(**request) + for event in response.get('events', []): + timestamp = ( + dt.datetime.fromtimestamp( + int(event['timestamp']) / 1000, + tz=dt.timezone.utc, + ) + .isoformat(timespec='milliseconds') + .replace('+00:00', 'Z') + ) + message = str(event.get('message', '')).rstrip() + print(f"[microvm-build {timestamp}] {message}") + token = response.get('nextToken') + if not token or token == request.get('nextToken'): + return + request['nextToken'] = token + + +def json_value(value: Any) -> Any: + if isinstance(value, (dt.date, dt.datetime)): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + raise TypeError(f"{type(value).__name__} is not JSON serializable") + + +def write_manifest(path: Path, value: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode='w', + encoding='utf-8', + dir=path.parent, + delete=False, + ) as temporary: + json.dump( + value, + temporary, + default=json_value, + indent=2, + sort_keys=True, + ) + temporary.write('\n') + temporary_path = Path(temporary.name) + os.replace(temporary_path, path) + + +def run() -> int: + settings = load_settings() + revision = source_revision() + session = aws_session(settings.region) + ecr = session.client('ecr', region_name=settings.region) + ubuntu_image = resolve_ubuntu_image(ecr, settings.ubuntu_image) + print(f"Using digest-pinned Ubuntu mirror: {ubuntu_image}") + + artifact = create_artifact(settings, ubuntu_image) + print(f"Packaged MicroVM artifact: {artifact.path}") + print(f"Artifact SHA-256: {artifact.sha256}") + + s3 = session.client('s3', region_name=settings.region) + artifact_uri = upload_artifact(s3, settings, artifact, revision) + print(f"Uploaded {artifact_uri}") + + client = microvm_client(session, settings.region) + existing_image_arn = find_image(client, settings.image_name) + request = build_request( + settings, + artifact_uri, + revision, + existing_image_arn, + ) + started_at = int(time.time() * 1000) - 5000 + response = start_build(client, request) + image_arn = str(response['imageArn']) + image_version = str(response['imageVersion']) + + manifest = { + 'artifactSha256': artifact.sha256, + 'artifactUri': artifact_uri, + 'egressNetworkConnectorArn': settings.egress_network_connector_arn, + 'imageArn': image_arn, + 'imageVersion': image_version, + 'logGroup': settings.log_group, + 'logStream': log_stream(settings), + 'name': settings.image_name, + 'operation': 'update' if existing_image_arn else 'create', + 'region': settings.region, + 'releaseVersion': settings.release_version, + 'sourceRevision': revision, + 'ubuntuBaseImage': ubuntu_image, + } + manifest_path = settings.output_dir / 'microvm-image.json' + write_manifest(manifest_path, manifest) + + try: + image, version = wait_for_image(client, image_arn, image_version) + finally: + try: + print_build_logs( + session.client('logs', region_name=settings.region), + settings, + started_at, + ) + except Exception as error: + print( + f"Warning: could not retrieve build logs: {error}", + file=sys.stderr, + ) + + manifest.update( + { + 'imageState': image.get('state'), + 'state': version.get('state'), + 'status': version.get('status'), + } + ) + write_manifest(manifest_path, manifest) + print( + f"Lambda MicroVM image is ready: " + f"{image_arn} version {image_version}" + ) + print(f"Manifest: {manifest_path}") + return 0 + + +def main() -> int: + try: + return run() + except KeyboardInterrupt: + print('Error: interrupted', file=sys.stderr) + return 130 + except Exception as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/.dockerignore b/images/microvm-ubuntu/packer/scripts/microvm/image/.dockerignore new file mode 100644 index 0000000000..7a60b85e14 --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/.dockerignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/image-entrypoint.sh b/images/microvm-ubuntu/packer/scripts/microvm/image/image-entrypoint.sh new file mode 100644 index 0000000000..5313aff275 --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/image-entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# shellcheck shell=bash + +# Start the compiled lifecycle-hook server from the supplied ZIP artifact. + +set -euo pipefail + +readonly hook_node="${MICROVM_HOOK_NODE:-/opt/actions-runner/externals/node24/bin/node}" +readonly hook_server="${MICROVM_HOOK_SERVER:-/opt/microvm/server.js}" + +if [[ ! -x "$hook_node" ]]; then + printf '[microvm] Lifecycle hook Node executable is unavailable: %s\n' \ + "$hook_node" >&2 + exit 1 +fi +if [[ ! -r "$hook_server" ]]; then + printf '[microvm] Lifecycle hook server is unavailable: %s\n' \ + "$hook_server" >&2 + exit 1 +fi + +exec "$hook_node" "$hook_server" diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/services/cloudwatch-agent.sh b/images/microvm-ubuntu/packer/scripts/microvm/image/services/cloudwatch-agent.sh new file mode 100644 index 0000000000..1924c7fcd8 --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/services/cloudwatch-agent.sh @@ -0,0 +1,49 @@ +#!/command/with-contenv bash +# shellcheck shell=bash + +set -euo pipefail + +readonly agent_root=/opt/aws/amazon-cloudwatch-agent +readonly config_directory=/etc/cwagentconfig +readonly config_path="${config_directory}/config.json" +readonly microvm_id="${MICROVM_ID:?}" +readonly runner_config_ssm_path="${RUNNER_CONFIG_SSM_PATH:?}" + +read_parameter() { + AWS_PAGER='' /usr/local/bin/aws ssm get-parameter \ + --name "$1" \ + --query Parameter.Value \ + --output text \ + --no-cli-pager +} + +enabled="$(read_parameter "${runner_config_ssm_path}/enable_cloudwatch")" +if [[ "$enabled" == false ]]; then + printf '[cloudwatch-agent] disabled by runner configuration\n' >&2 + /command/s6-svc -d /run/service/cloudwatch-agent + exit 0 +fi +if [[ "$enabled" != true ]]; then + printf '[cloudwatch-agent] enable_cloudwatch must be true or false\n' >&2 + exit 1 +fi + +install -d -m 0700 -o root -g root "$config_directory" +umask 077 +read_parameter "${runner_config_ssm_path}/cloudwatch_agent_config_runner" | + MICROVM_ID="$microvm_id" jq --exit-status ' + select(type == "object") | + walk( + if type == "string" then + gsub("\\{microvm_id\\}"; env.MICROVM_ID) + else + . + end + ) +' >"$config_path" +chmod 0600 "$config_path" + +exec env \ + RUN_IN_AWS=True \ + RUN_IN_CONTAINER=True \ + "${agent_root}/bin/start-amazon-cloudwatch-agent" diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/start-services.sh b/images/microvm-ubuntu/packer/scripts/microvm/image/start-services.sh new file mode 100644 index 0000000000..5865ed1758 --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/start-services.sh @@ -0,0 +1,39 @@ +#!/command/with-contenv bash +# shellcheck shell=bash + +set -euo pipefail + +readonly internal_services_log=/var/log/microvm/internal-services.log +readonly microvm_id="${MICROVM_ID:?}" +readonly runner_config_ssm_path="${RUNNER_CONFIG_SSM_PATH:?}" +readonly s6_environment=/run/s6/container_environment + +exec > >(/usr/bin/tee --append -- "$internal_services_log") +exec 2> >(/usr/bin/tee --append -- "$internal_services_log" >&2) + +if [[ -z "${MICROVM_SERVICES:-}" ]]; then + exit 0 +fi + +IFS=',' read -r -a services <<<"${MICROVM_SERVICES}" +for service in "${services[@]}"; do + [[ -z "$service" ]] && continue + if [[ ! "$service" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]]; then + printf '[microvm-services] invalid service name: %s\n' "$service" >&2 + exit 2 + fi + if [[ ! -d "/run/service/${service}" ]]; then + printf '[microvm-services] service is unavailable: %s\n' "$service" >&2 + exit 1 + fi +done + +printf '%s' "$microvm_id" >"${s6_environment}/MICROVM_ID" +chmod 0600 "${s6_environment}/MICROVM_ID" +printf '%s' "$runner_config_ssm_path" >"${s6_environment}/RUNNER_CONFIG_SSM_PATH" +chmod 0600 "${s6_environment}/RUNNER_CONFIG_SSM_PATH" + +for service in "${services[@]}"; do + [[ -z "$service" ]] && continue + /command/s6-svc -u "/run/service/${service}" +done diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/ubuntu24.arm64.Dockerfile b/images/microvm-ubuntu/packer/scripts/microvm/image/ubuntu24.arm64.Dockerfile new file mode 100644 index 0000000000..c60fc1316e --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/ubuntu24.arm64.Dockerfile @@ -0,0 +1,174 @@ +# syntax=docker/dockerfile:1 + +# Lambda MicroVMs currently run ARM64 images. The image contains the Actions +# runner, CloudWatch Agent, S6 overlay, and compiled lifecycle-hook server. +ARG UBUNTU_IMAGE + +# hadolint ignore=DL3006 +FROM ${UBUNTU_IMAGE} AS tooling + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ARG AWS_CLI_VERSION="2.36.24" +ARG AWS_CLI_SHA256=c024c45a9d22005f81c7c0fab9e23ee7118ffa210d812845b42e980cf93727a7 + +ARG RUNNER_VERSION="2.336.0" +ARG RUNNER_SHA256=58b758e420b87093fbd4bfddd368074960053e2f1388f01848c82624b90f27d1 + +ARG CLOUDWATCH_AGENT_VERSION=1.300071.0b1720 + +# S6 overlay is pinned and verified before it is copied into the runtime image. +ARG S6_OVERLAY_VERSION="3.2.3.2" +ARG S6_OVERLAY_NOARCH_SHA256=5379750ed30a84bbd2e2dd74847ba6b5bd29cd0b2e3ea2ec58049b57eb2eda12 +ARG S6_OVERLAY_AARCH64_SHA256=b17f17a82e7a515c682a91edaf2ffdabb73f891981b6c1fd712115693a2f8b4c + +# These packages are used only while assembling the runtime payload. +# hadolint ignore=DL3008,DL3015 +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + tar \ + unzip \ + xz-utils \ + && rm -rf /var/lib/apt/lists/* + +RUN install -d -m 0755 \ + /export/usr/local/aws-cli \ + /export/usr/local/bin \ + /export/opt/actions-runner \ + /export/opt/microvm \ + /export/run/amazon \ + /export/s6 \ + && curl --fail --location --show-error --silent \ + "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz" \ + --output /tmp/actions-runner.tar.gz \ + && printf '%s %s\n' "${RUNNER_SHA256}" /tmp/actions-runner.tar.gz | sha256sum --check --strict \ + && tar --extract --gzip --no-same-owner --file /tmp/actions-runner.tar.gz \ + --directory /export/opt/actions-runner \ + && test -x /export/opt/actions-runner/externals/node24/bin/node \ + && rm -f /tmp/actions-runner.tar.gz + +RUN curl --fail --location --show-error --silent \ + "https://amazoncloudwatch-agent.s3.amazonaws.com/ubuntu/arm64/${CLOUDWATCH_AGENT_VERSION}/amazon-cloudwatch-agent.deb" \ + --output /tmp/amazon-cloudwatch-agent.deb \ + && install -d -m 0755 /tmp/cloudwatch-agent-root \ + && dpkg-deb --extract /tmp/amazon-cloudwatch-agent.deb /tmp/cloudwatch-agent-root \ + && test "$(cat /tmp/cloudwatch-agent-root/opt/aws/amazon-cloudwatch-agent/bin/CWAGENT_VERSION)" \ + = "${CLOUDWATCH_AGENT_VERSION}" \ + && install -d -m 0755 /tmp/cloudwatch-agent-root/run/amazon \ + && mv /tmp/cloudwatch-agent-root/var/run/amazon/amazon-cloudwatch-agent \ + /tmp/cloudwatch-agent-root/run/amazon/ \ + && rmdir /tmp/cloudwatch-agent-root/var/run/amazon /tmp/cloudwatch-agent-root/var/run \ + && cp -a /tmp/cloudwatch-agent-root/. /export/ \ + && rm -f /tmp/amazon-cloudwatch-agent.deb \ + && rm -rf /tmp/cloudwatch-agent-root /export/etc/init /export/etc/systemd + +RUN curl --fail --location --show-error --silent \ + "https://awscli.amazonaws.com/awscli-exe-linux-aarch64-${AWS_CLI_VERSION}.zip" \ + --output /tmp/awscliv2.zip \ + && printf '%s %s\n' "${AWS_CLI_SHA256}" /tmp/awscliv2.zip | sha256sum --check --strict \ + && unzip -q /tmp/awscliv2.zip -d /tmp \ + && /tmp/aws/install \ + --install-dir /export/usr/local/aws-cli \ + --bin-dir /export/usr/local/bin \ + && rm -f /export/usr/local/bin/aws /export/usr/local/bin/aws_completer \ + && ln -s ../aws-cli/v2/current/bin/aws /export/usr/local/bin/aws \ + && ln -s ../aws-cli/v2/current/bin/aws_completer /export/usr/local/bin/aws_completer \ + && rm -rf /tmp/aws /tmp/awscliv2.zip + +RUN curl --fail --location --show-error --silent \ + "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" \ + --output /tmp/s6-overlay-noarch.tar.xz \ + && curl --fail --location --show-error --silent \ + "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-aarch64.tar.xz" \ + --output /tmp/s6-overlay-aarch64.tar.xz \ + && printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" \ + /tmp/s6-overlay-noarch.tar.xz | sha256sum --check --strict \ + && printf '%s %s\n' "${S6_OVERLAY_AARCH64_SHA256}" \ + /tmp/s6-overlay-aarch64.tar.xz | sha256sum --check --strict \ + && tar --extract --xz --preserve-permissions --file /tmp/s6-overlay-noarch.tar.xz \ + --directory /export \ + && tar --extract --xz --preserve-permissions --file /tmp/s6-overlay-aarch64.tar.xz \ + --directory /export \ + && rm -f /tmp/s6-overlay-noarch.tar.xz /tmp/s6-overlay-aarch64.tar.xz + +COPY lifecycle-hook.zip /tmp/lifecycle-hook.zip +RUN unzip -q /tmp/lifecycle-hook.zip -d /export/opt/microvm \ + && test -r /export/opt/microvm/server.js \ + && rm -f /tmp/lifecycle-hook.zip + +FROM ${UBUNTU_IMAGE} + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# These are the Actions runner runtime dependencies. Keep the list aligned +# with the runner's supported Ubuntu dependencies. +# hadolint ignore=DL3008,DL3015 +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + jq \ + libicu74 \ + libkrb5-3 \ + liblttng-ust1t64 \ + libssl3t64 \ + zlib1g \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=tooling /export/ / + +RUN existing_group="$(getent group 1000 | cut -d: -f1)" \ + && if [ -n "${existing_group}" ]; then \ + groupmod --new-name runner "${existing_group}"; \ + else \ + groupadd --gid 1000 runner; \ + fi \ + && existing_user="$(getent passwd 1000 | cut -d: -f1)" \ + && if [ -n "${existing_user}" ]; then \ + usermod --login runner --home /home/runner --move-home \ + --shell /bin/bash "${existing_user}"; \ + else \ + useradd --create-home --home-dir /home/runner --shell /bin/bash \ + --uid 1000 --gid 1000 runner; \ + fi \ + && install -d -m 0755 /opt/microvm /etc/services.d/cloudwatch-agent /var/log/microvm \ + && install -m 0600 /dev/null /var/log/microvm/internal-services.log \ + && install -m 0600 /dev/null /var/log/microvm/run.log \ + && chown -R runner:runner /home/runner /opt/actions-runner + +COPY --chmod=0555 image-entrypoint.sh /opt/microvm/image-entrypoint.sh +COPY --chmod=0555 start-services.sh /opt/microvm/start-services.sh +COPY --chmod=0755 services/cloudwatch-agent.sh /etc/services.d/cloudwatch-agent/run +RUN touch /etc/services.d/cloudwatch-agent/down \ + && chmod 0644 /etc/services.d/cloudwatch-agent/down + +ENV ACTIONS_RUNNER_ROOT="/opt/actions-runner" \ + AGENT_TOOLSDIRECTORY="/opt/hostedtoolcache" \ + HOME="/home/runner" \ + HOOK_PORT="8080" \ + INTERNAL_SERVICES="/opt/microvm/start-services.sh" \ + MICROVM_HOOK_LOG_FILE="/var/log/microvm/run.log" \ + MICROVM_HOOK_NODE="/opt/actions-runner/externals/node24/bin/node" \ + MICROVM_HOOK_SERVER="/opt/microvm/server.js" \ + MICROVM_SERVICES="cloudwatch-agent" \ + RUN_HOOK_TIMEOUT_SECONDS="52" \ + RUNNER_CONFIG_POLL_SECONDS="2" \ + RUNNER_CONFIG_TIMEOUT_SECONDS="20" \ + RUNNER_GID="1000" \ + RUNNER_HOME="/home/runner" \ + RUNNER_LAUNCH_RESERVE_SECONDS="7" \ + RUNNER_ROOT="/opt/actions-runner" \ + RUNNER_UID="1000" \ + RUNNER_USER="runner" \ + RUNNER_TOOL_CACHE="/opt/hostedtoolcache" \ + RUNNER_TOOLSDIRECTORY="/opt/hostedtoolcache" + +# The lifecycle hook owns the MicroVM control socket and log file. +# hadolint ignore=DL3002 +USER 0 +WORKDIR /opt/actions-runner +EXPOSE 8080 +ENTRYPOINT ["/init"] +CMD ["/command/with-contenv", "/opt/microvm/image-entrypoint.sh"] diff --git a/modules/microvm-foundation/README.md b/modules/microvm-foundation/README.md index fadbedc0f7..2c56e7fa8f 100644 --- a/modules/microvm-foundation/README.md +++ b/modules/microvm-foundation/README.md @@ -51,7 +51,7 @@ module "microvm_foundation" { The companion `examples/microvm-foundation` directory is a complete setup example. Apply it before following the direct Packer build instructions in -`images/microvm/README.md` or using the `examples/microvm` runner example. +`images/microvm-ubuntu/README.md` or using the `examples/microvm` runner example. ## Requirements From 90a26ea49eff8261bfd7e0f604c0606d639fd6fc Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 17 Sep 2026 14:03:31 +0200 Subject: [PATCH 03/54] chore: fix lock providers --- examples/microvm/.terraform.lock.hcl | 3 + examples/microvm/.terraform.lock.hcl.tofu | 113 ++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 examples/microvm/.terraform.lock.hcl.tofu diff --git a/examples/microvm/.terraform.lock.hcl b/examples/microvm/.terraform.lock.hcl index 7a131aab93..e46d40b514 100644 --- a/examples/microvm/.terraform.lock.hcl +++ b/examples/microvm/.terraform.lock.hcl @@ -5,6 +5,7 @@ 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", @@ -29,6 +30,7 @@ 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", @@ -51,6 +53,7 @@ provider "registry.terraform.io/hashicorp/random" { constraints = "~> 3.0" hashes = [ "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", diff --git a/examples/microvm/.terraform.lock.hcl.tofu b/examples/microvm/.terraform.lock.hcl.tofu new file mode 100644 index 0000000000..7aa235531d --- /dev/null +++ b/examples/microvm/.terraform.lock.hcl.tofu @@ -0,0 +1,113 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" + hashes = [ + "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", + "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", + "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", + "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", + "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", + "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", + "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", + "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", + "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", + "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", + "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", + "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", + "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", + "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", + "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", + "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", + "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", + "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", + "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", + "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", + "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", + "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", + "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", + "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", + "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", + "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", + "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", + "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", + "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", + "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", + ] +} + +provider "registry.opentofu.org/hashicorp/null" { + version = "3.3.1" + constraints = "~> 3.0, ~> 3.2" + hashes = [ + "h1:2wld81FnmHW0WVgy081sIfokCr2+NuatS8yjeLEet7Y=", + "h1:AClQjJ6X22V4qcRgcYSxiXCMmp2pz0G8WVQC7wAx66o=", + "h1:AY3XQbuviNd2X5VhHYEbhNta1m/CG3JD2BKFKhCt1Y4=", + "h1:CUOZUd7H11lsU+4tISlnYIiP5BqnX8IDwFCVqfLJyAg=", + "h1:JIfV0nA/pLWnIFGscvTfuavQCn2NeHxJBeb6UUg/joA=", + "h1:RejAh+nyCwqDGExGln2Kb4Ro5LyHak0eJe0P9g8CHPc=", + "h1:SHOuTZjYymsmy4asuRq6NC3yW+zdVZOOt4f5nrb+EPM=", + "h1:WwPat/gT4gO8GvvKNdSkkXWVD65JppLJfqKOt9HhOqQ=", + "h1:Z3hXVLrOyaRiiLmmL5UCOdcRMguwjN1x5TYNdmBDgls=", + "h1:dd78Ad5HdfPzPts7A9qIxfitXhAriV/qza38fr2ukjk=", + "h1:dyVb++KwDdybzLTE6bf7GZiVQ31iWsgKPWmhTQ8G42k=", + "h1:gD8ZH6WWe+5gg5+y8SpLWGPUDzSxcQ3HKP8IDM/wW3I=", + "h1:juXCww0zRQKFTDZoKqYR0+Sn1lu99oeL6pr0Jh6LWx0=", + "h1:kFAySmtsshyNV7IhIrEdASzVcvwy68eeZCVC66P7yNk=", + "h1:nS5azDopRisB2NInwDx3Hrfg2FdVt8Gw0gTQzC0rd70=", + "zh:164eb061d84e01759f391265865fb31828083d0a06b25f7af7e094cbdb18c799", + "zh:1bb9b669a82b52c0cba2860c71e9ee6699ef302f28cb8ed06f572d39bc6c7c4f", + "zh:1ea9b31a8f29302122c1e8d673693f3ac270336dae560af803cd1117265a469a", + "zh:238bd463cb0154fb935dc331da40c0a9cbe5db9cee615ae5f35ccad5eed7dc41", + "zh:30ef2b7384cf7e20f33fe75754b54cf669d59816f3ad4fc73bfb2b26fb6735e9", + "zh:35b5cded16e4b57c207d03ee0979b14baf486fa520e6edb7a2eecf18f1b85471", + "zh:3dc840d13a50cd215c7540573f27e2b61f739ba90aee5b7c3846079aa0ab5534", + "zh:3f9309a18db608f975d5691fcb47a6e14d77199156a52e9c39dcafe3737f2b07", + "zh:44263a219f7dbd1848b545d080110b4f7d0495e77b71cd3c7a0b5ec52a09accb", + "zh:4dec54aa5f445eeea035bbd4839bcded5e47ecd07cba0e70c5a09e9272cb592f", + "zh:5e8fb319d7c6d6c4566a18b9d0c91580b4901a96acd7fdc476bfc79f074368e2", + "zh:b0e8b6d41834b57fcfbb5ca00da52ccb757e1a95b6a2d546c0dae8bfbeca1cdf", + "zh:bbde4c3a1dcc1718027a61a4cdf661619d17af1b58df1038fe27bcf43c3dc29b", + "zh:c4140fff9f692baf29236557f706f9515f93229413438527d764023a82301da3", + "zh:f8e9d83184e4bbeb97c6f0d569833007c48ba5a7ff334def201df4991d03a962", + ] +} + +provider "registry.opentofu.org/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:8EQU5KSxezcjo/phRSe69rDOI0lk4pSaggj7FsskYp8=", + "h1:Lw9im2VBBJQ3RyAbHPQ0rcvcmmcZWm3x+kIOpN+Tv9s=", + "h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=", + "h1:YXaVd4p6qXPPVaxIBaIDNXmBwT02ZqDn0qD+tYpw8sA=", + "h1:cOpc03fphEt/G9Rfc4jLL/fW0D7tgvlXqiDKPF4vuww=", + "h1:g09RR7T1xWkeGrZwWvWMT9ncJrFGr1k3CBD585UmO7w=", + "h1:gGDdPPibmw2EWROx+sh1RGLjR5+nPwZyrf6/N9jXfeM=", + "h1:haE7/nXCOhXKP4oXeEnER3t5CaVQWqujz4nBnpeTUv4=", + "h1:ieSVpfZS2lKuMr05ph0QsOVpCzg7uk3cgKBaXR+Ikug=", + "h1:ig2s1IS9IzehorRjvVAnKIsUUj8fkgyxct1L/kswcc4=", + "h1:j3lS+ZEERFnoab8t1ppDrScGVP/cgWbzlCrEYKTCXYw=", + "h1:lxezrKmOiQIySHAM+os8qLVq7hqufDr8h3Hpzvsk+78=", + "h1:lzRqBJAG+NETxHbEZUJ/YP3RMEjZBinTX7VmgH3lw60=", + "h1:tdSNWK5ApqUsgbdYieyeYLTu6nIZUV3hR1oFqUfAuGo=", + "h1:xedet8yH/zI2CfdxsGlK0nlFWc/Bp61yrWsEa3fHB8g=", + "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", + "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", + "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", + "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", + "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", + "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", + "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", + "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", + "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", + "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", + "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", + "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", + "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", + "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", + "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", + ] +} From 23e414b2cd60fb714019d09d2d6d385f65e55fbf Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 17 Sep 2026 14:30:08 +0200 Subject: [PATCH 04/54] test: fix tftest for microvm --- examples/microvm/main.tf | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/examples/microvm/main.tf b/examples/microvm/main.tf index e3b2aaa6b4..ca43e5044b 100644 --- a/examples/microvm/main.tf +++ b/examples/microvm/main.tf @@ -14,24 +14,13 @@ module "runners" { source = "../../modules/multi-runner" aws_region = local.aws_region - vpc_id = module.base.vpc.vpc_id - subnet_ids = module.base.vpc.private_subnets prefix = local.environment - # Required for backwards-compatible module input validation; the non-empty - # experimental map selects the MicroVM configuration below. - multi_runner_config = {} - - # Keep GitHub App credentials in pre-created SSM parameters. This example - # therefore does not place the private key or webhook secret in Terraform - # configuration or state. - github_app = var.github_app - - experimental_global_config_github = { + global_config_github = { app = var.github_app } - experimental_global_config_lambda = { + global_config_lambda = { artifact = { s3 = { bucket = var.lambda_artifact_bucket @@ -39,7 +28,7 @@ module "runners" { } } - experimental_global_config_orchestration_provider = { + global_config_orchestration_provider = { webhook = { runner = { ephemeral = true @@ -67,13 +56,13 @@ module "runners" { } } - experimental_global_config_ssm = { + global_config_ssm = { paths = { root = "/github-action-runners/${local.environment}" } } - experimental_global_config_compute_provider = { + global_config_compute_provider = { aws = { microvm = { image_arn = var.microvm_image_arn @@ -84,7 +73,7 @@ module "runners" { } } - experimental_multi_runner_config = { + multi_runner_config = { microvm = { runner = { os = "linux" From 0e4982896f0f24fa3cc8a1d08d122ed6275315c5 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 3 Sep 2026 23:32:05 +0200 Subject: [PATCH 05/54] feat(multi-runner): integrate Lambda MicroVM provider --- .../config.experimental.translation.tf | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 915b2d9276..8257907442 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -578,30 +578,7 @@ locals { log_files = v.runner_config.runner_log_files tags = v.runner_config.runner_ec2_tags } - microvm = { - image_arn = null - image_version = null - ingress_network_connectors = [] - egress_network_connectors = [] - cloudwatch_agent = { - enabled = true - config = null - } - log_files = null - environment_variables = {} - iam = { - resource_arns = { - images = null - } - additional_policy_json = { - scale_up = null - } - managed_policies = { - scale_up = null - pool = null - } - } - } + microvm = null } } } From 6dc39190e1b2a348cef3e809f0065f12e68bdd7a Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 22:19:51 +0200 Subject: [PATCH 06/54] fix(multi-runner): default translated MicroVM config --- .../config.experimental.translation.tf | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 8257907442..915b2d9276 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -578,7 +578,30 @@ locals { log_files = v.runner_config.runner_log_files tags = v.runner_config.runner_ec2_tags } - microvm = null + microvm = { + image_arn = null + image_version = null + ingress_network_connectors = [] + egress_network_connectors = [] + cloudwatch_agent = { + enabled = true + config = null + } + log_files = null + environment_variables = {} + iam = { + resource_arns = { + images = null + } + additional_policy_json = { + scale_up = null + } + managed_policies = { + scale_up = null + pool = null + } + } + } } } } From e7bc8ea8ff1fe4f9e18b5fbdc8497a244ad9d942 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:25:22 +0000 Subject: [PATCH 07/54] docs: auto update terraform docs --- modules/multi-runner/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 809b915290..b632ee52aa 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -102,7 +102,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -110,7 +110,7 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | 6.63.0 | | [random](#provider\_random) | 3.9.0 | | [terraform](#provider\_terraform) | n/a | @@ -118,7 +118,7 @@ module "multi-runner" { ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -130,7 +130,7 @@ module "multi-runner" { ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -143,7 +143,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
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 }))
}))
| `[]` | no | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
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)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | @@ -168,7 +168,7 @@ module "multi-runner" { | [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\_confirmation\_seconds: "Seconds a runner must consistently report not-busy before scale-down terminates it; 0 disables the confirmation window."
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_confirmation_seconds = optional(number, 0)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
})
| `{}` | no | +| [global\_config\_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\_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 | @@ -190,7 +190,7 @@ module "multi-runner" { | [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."
scale\_down\_idle\_confirmation\_seconds: "Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale, so a single not-busy reading is not sufficient evidence a runner is idle. 0 keeps the previous single-reading behaviour."
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)
scale_down_idle_confirmation_seconds = optional(number, 0)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
}), null)
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})

# V2 Contract
tags = optional(map(string), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
organization_runners = optional(bool, false)
}), {})
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
}), null)
queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})
lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
}), {})
}))
| `{}` | 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)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), 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 | @@ -235,7 +235,7 @@ module "multi-runner" { ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | From 6d6224a4832f9b48c006d94bc5ce39e6426a203f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:03:22 +0000 Subject: [PATCH 08/54] docs: auto update terraform docs --- modules/multi-runner/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index b632ee52aa..635e078570 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -102,7 +102,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -110,7 +110,7 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | 6.63.0 | | [random](#provider\_random) | 3.9.0 | | [terraform](#provider\_terraform) | n/a | @@ -118,7 +118,7 @@ module "multi-runner" { ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -130,7 +130,7 @@ module "multi-runner" { ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -143,7 +143,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
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 }))
}))
| `[]` | no | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
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)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | @@ -235,7 +235,7 @@ module "multi-runner" { ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | From 9e89bef36cc88d052f4e64bd662c42e7942cb738 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 22:54:52 +0200 Subject: [PATCH 09/54] fix(microvm): support Terraform 1.4 validation --- .../aws/microvm/tests/provider.tftest.hcl | 16 ++++++++++++++++ .../compute-providers/aws/microvm/validations.tf | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl b/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl index de167845f2..81f417e21c 100644 --- a/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl +++ b/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl @@ -546,6 +546,22 @@ run "rejects_invalid_metadata_path" { expect_failures = [terraform_data.validate_config] } +run "rejects_metadata_path_with_duplicate_separators" { + command = plan + + variables { + ssm = { + paths = { + root = "/github-action-runners" + tokens = "tokens" + config = "config//invalid" + } + } + } + + expect_failures = [terraform_data.validate_config] +} + run "rejects_invalid_image_resource_allowlist" { command = plan diff --git a/modules/compute-providers/aws/microvm/validations.tf b/modules/compute-providers/aws/microvm/validations.tf index 75c2aeb33b..17c78b1d81 100644 --- a/modules/compute-providers/aws/microvm/validations.tf +++ b/modules/compute-providers/aws/microvm/validations.tf @@ -58,7 +58,7 @@ resource "terraform_data" "validate_config" { trim(var.ssm.paths.root, "/") != "" && trim(var.ssm.paths.config, "/") != "" && can(regex("^/[A-Za-z0-9_./-]+$", local.microvm_metadata_ssm_path)) && - !strcontains(local.microvm_metadata_ssm_path, "//") + length(regexall("//", local.microvm_metadata_ssm_path)) == 0 ) error_message = "The derived MicroVM metadata Parameter Store path must be an absolute path containing only letters, numbers, dot, underscore, hyphen, and slash." } From 753e4b5999a25a8019bad0e4c7c15684e944e2c9 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 23:05:45 +0200 Subject: [PATCH 10/54] fix(multi-runner): align v2 configuration tests --- modules/multi-runner/validations.tf | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf index 5de521c1bb..4be542eb59 100644 --- a/modules/multi-runner/validations.tf +++ b/modules/multi-runner/validations.tf @@ -68,11 +68,10 @@ resource "terraform_data" "validate_v2" { 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.webhook != null, false) ) ]) - error_message = "Each experimental v2 runner lane requires a webhook matcher." + error_message = "Each experimental v2 runner lane requires a webhook provider." } precondition { From b90d263a00e05510f97668efa6f88489efdb0558 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 23:44:24 +0200 Subject: [PATCH 11/54] docs(adr): clarify runner-config boundary --- ...-runner-orchestration-provider-boundary.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index 3d77e2f3cd..06dc16a875 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -229,6 +229,36 @@ not below `modules/runner-config`. This keeps the common composition module small and prevents provider-owned resources from becoming part of the common contract. +### `runner-config` is the provider-neutral composition boundary + +`modules/runner-config` is an internal composition module selected by +`multi-runner`; it is not a standalone public entry point. It receives one +resolved runner configuration and owns the common runner identity, IAM role, +runner bootstrap parameters, SSM housekeeper composition, and the capability +connections between the selected providers. + +`runner-config` dispatches exactly one typed orchestration provider and one +typed compute provider. Provider selection is made from the plan-known typed +wrappers, not from a string discriminator or runtime fallback. The selected +provider receives the resolved common runner settings and returns only the +provider-specific resources, environment variables, IAM fragments, and +outputs required by the orchestration provider. + +Webhook queues, Lambda functions, schedules, and retry behavior remain owned +by the webhook orchestration provider. EC2 instances, Lambda MicroVM capacity, +image publication, and provider-specific bootstrap behavior remain owned by +their compute providers. `runner-config` connects these capabilities but does +not absorb either provider's implementation. + +For Lambda MicroVM runners, the image is an immutable runtime artifact. The +runner configuration and its sensitive, short-lived bootstrap value are +published through the runner-config SSM contract and retrieved when the +MicroVM starts. Tenant-specific runner configuration, registration tokens, and +JIT payloads must not be baked into the image or its Terraform configuration. +The image therefore supplies the runner and lifecycle-hook runtime, while the +selected compute provider supplies the lane-specific SSM path and execution +permissions. + ```mermaid flowchart TD Multi["multi-runner: translate and resolve"] --> Config["runner-config: compose one runner config"] From 8d9ce5ce45c78f3190ce6d2c9b6bc99bca05a0e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:22:13 +0000 Subject: [PATCH 12/54] docs: auto update terraform docs --- modules/multi-runner/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 635e078570..809b915290 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -168,7 +168,7 @@ module "multi-runner" { | [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\_confirmation\_seconds: "Seconds a runner must consistently report not-busy before scale-down terminates it; 0 disables the confirmation window."
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_confirmation_seconds = optional(number, 0)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
})
| `{}` | no | | [global\_config\_ssm](#input\_global\_config\_ssm) | Global SSM configuration shared by all runner lanes.

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 | @@ -190,7 +190,7 @@ module "multi-runner" { | [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)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
}), {})
}))
| `{}` | 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."
scale\_down\_idle\_confirmation\_seconds: "Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale, so a single not-busy reading is not sufficient evidence a runner is idle. 0 keeps the previous single-reading behaviour."
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)
scale_down_idle_confirmation_seconds = optional(number, 0)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
}), null)
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})

# V2 Contract
tags = optional(map(string), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
organization_runners = optional(bool, false)
}), {})
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
}), null)
queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})
lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), 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 | From 85aeddc8da47ca557356da7531d471c3e78d8e41 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 21:16:16 +0200 Subject: [PATCH 13/54] test(ministack): run microvm foundation example --- .github/workflows/ministack.yml | 1 + tests/ministack/microvm-foundation.tfvars | 3 +++ tests/ministack/run-example.sh | 6 +++--- 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 tests/ministack/microvm-foundation.tfvars diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 5aec51a53c..c28a249abf 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -72,6 +72,7 @@ jobs: - ephemeral - multi-runner - multi-runner-v2 + - microvm-foundation - termination-watcher services: ministack: diff --git a/tests/ministack/microvm-foundation.tfvars b/tests/ministack/microvm-foundation.tfvars new file mode 100644 index 0000000000..9d576d77c7 --- /dev/null +++ b/tests/ministack/microvm-foundation.tfvars @@ -0,0 +1,3 @@ +aws_region = "eu-west-1" + +network_connectors = {} diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index 9ea64e58fc..ff86baf566 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -23,14 +23,14 @@ case "$iac_binary" in esac case "$example" in - base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2) + base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation) use_tfvars=true ;; termination-watcher) use_tfvars=false ;; *) - echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, termination-watcher" >&2 + echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, microvm-foundation, termination-watcher" >&2 exit 64 ;; esac @@ -38,7 +38,7 @@ esac case "$action" in init | plan | apply | destroy) ;; *) - echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|termination-watcher} [TFVARS_FILE]" >&2 + echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|microvm-foundation|termination-watcher} [TFVARS_FILE]" >&2 exit 64 ;; esac From 2602c4f026ac00093450d39cde5963129a9bb9fa Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 23:40:33 +0200 Subject: [PATCH 14/54] fix(microvm): align foundation example inputs --- examples/microvm-foundation/README.md | 2 +- examples/microvm-foundation/variables.tf | 2 +- modules/microvm-foundation/README.md | 4 ++-- modules/microvm-foundation/variables.tf | 13 ++++--------- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/examples/microvm-foundation/README.md b/examples/microvm-foundation/README.md index 841552abd5..4ceb112d29 100644 --- a/examples/microvm-foundation/README.md +++ b/examples/microvm-foundation/README.md @@ -60,7 +60,7 @@ No resources. | [build\_role\_name\_prefix](#input\_build\_role\_name\_prefix) | Name prefix for the Lambda MicroVM build role. | `string` | `"github-actions-runner-microvm-build-"` | no | | [ecr\_repository\_arns](#input\_ecr\_repository\_arns) | Optional private ECR repository ARNs used by the image build. | `set(string)` | `[]` | no | | [image\_name\_prefix](#input\_image\_name\_prefix) | Reserved Lambda MicroVM image-name namespace used by the runtime policy. | `string` | `"github-actions-runner-ubuntu-arm64"` | no | -| [network\_connector\_operator\_role\_name\_prefix](#input\_network\_connector\_operator\_role\_name\_prefix) | Name prefix for the Lambda Network Connector operator role. | `string` | `"github-actions-runner-microvm-network-operator-"` | no | +| [network\_connector\_operator\_role\_name\_prefix](#input\_network\_connector\_operator\_role\_name\_prefix) | Name prefix for the Lambda Network Connector operator role. | `string` | `"github-actions-microvm-net-operator-"` | no | | [network\_connectors](#input\_network\_connectors) | VPC and subnet configuration for regional Lambda MicroVM egress connectors. |
map(object({
name = string
vpc_id = string
subnet_ids = set(string)
network_protocol = optional(string, "IPv4")
}))
| n/a | yes | | [tags](#input\_tags) | Additional tags applied by the foundation module. | `map(string)` |
{
"Component": "microvm-foundation"
}
| no | | [usage\_policy\_name\_prefix](#input\_usage\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM runtime usage policy. | `string` | `"github-actions-runner-microvm-runtime-usage-policy-"` | no | diff --git a/examples/microvm-foundation/variables.tf b/examples/microvm-foundation/variables.tf index 4afd3804a5..aa8d9fb002 100644 --- a/examples/microvm-foundation/variables.tf +++ b/examples/microvm-foundation/variables.tf @@ -40,7 +40,7 @@ variable "build_role_name_prefix" { variable "network_connector_operator_role_name_prefix" { type = string description = "Name prefix for the Lambda Network Connector operator role." - default = "github-actions-runner-microvm-network-operator-" + default = "github-actions-microvm-net-operator-" } variable "artifact_bucket_name" { diff --git a/modules/microvm-foundation/README.md b/modules/microvm-foundation/README.md index 2c56e7fa8f..15747640f8 100644 --- a/modules/microvm-foundation/README.md +++ b/modules/microvm-foundation/README.md @@ -34,7 +34,7 @@ module "microvm_foundation" { tags = { Environment = "example" } build_policy_name_prefix = "github-actions-runner-microvm-build-policy-" build_role_name_prefix = "github-actions-runner-microvm-build-" - network_connector_operator_role_name_prefix = "github-actions-runner-microvm-network-operator-" + network_connector_operator_role_name_prefix = "github-actions-microvm-net-operator-" usage_policy_name_prefix = "github-actions-runner-microvm-runtime-usage-policy-" image_name_prefix = "github-actions-runner-ubuntu-arm64" @@ -116,7 +116,7 @@ No modules. | [ecr\_repository\_arns](#input\_ecr\_repository\_arns) | Optional regional ECR repository ARNs from which MicroVM image builds can pull runner base images. | `set(string)` | `[]` | no | | [image\_name\_prefix](#input\_image\_name\_prefix) | IAM namespace prefix reserved for externally published Lambda MicroVM image names. This module does not create or enumerate images. | `string` | n/a | yes | | [network\_connector\_operator\_role\_name\_prefix](#input\_network\_connector\_operator\_role\_name\_prefix) | Name prefix for the Lambda Network Connector operator role. | `string` | n/a | yes | -| [network\_connectors](#input\_network\_connectors) | Regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity. |
map(object({
name = string
vpc_id = string
subnet_ids = set(string)
network_protocol = optional(string, "IPv4")
}))
| n/a | yes | +| [network\_connectors](#input\_network\_connectors) | Optional regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity. |
map(object({
name = string
vpc_id = string
subnet_ids = set(string)
network_protocol = optional(string, "IPv4")
}))
| n/a | yes | | [tags](#input\_tags) | A map of module-specific tags to apply to resources. | `map(string)` | n/a | yes | | [usage\_policy\_name\_prefix](#input\_usage\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM runtime usage policy. | `string` | n/a | yes | diff --git a/modules/microvm-foundation/variables.tf b/modules/microvm-foundation/variables.tf index 81c84d4fd1..72c1db000b 100644 --- a/modules/microvm-foundation/variables.tf +++ b/modules/microvm-foundation/variables.tf @@ -43,8 +43,8 @@ variable "network_connector_operator_role_name_prefix" { description = "Name prefix for the Lambda Network Connector operator role." validation { - condition = length(var.network_connector_operator_role_name_prefix) >= 1 && length(var.network_connector_operator_role_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.network_connector_operator_role_name_prefix)) - error_message = "network_connector_operator_role_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + condition = length(var.network_connector_operator_role_name_prefix) >= 1 && length(var.network_connector_operator_role_name_prefix) <= 38 && can(regex("^[a-zA-Z0-9-_]+-$", var.network_connector_operator_role_name_prefix)) + error_message = "network_connector_operator_role_name_prefix must be 1 to 38 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." } } @@ -55,7 +55,7 @@ variable "artifact_bucket_name" { nullable = true validation { - condition = var.artifact_bucket_name == null || length(var.artifact_bucket_name) > 0 + condition = var.artifact_bucket_name == null ? true : length(var.artifact_bucket_name) > 0 error_message = "artifact_bucket_name must be null or a non-empty string." } } @@ -98,12 +98,7 @@ variable "network_connectors" { subnet_ids = set(string) network_protocol = optional(string, "IPv4") })) - description = "Regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity." - - validation { - condition = length(var.network_connectors) > 0 - error_message = "network_connectors must contain at least one connector." - } + description = "Optional regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity." validation { condition = alltrue([ From a20283b80979dac56a4d964de952fed1014d9890 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 9 Sep 2026 11:13:24 +0200 Subject: [PATCH 15/54] fix(ministack): configure microvm connector fixture --- tests/ministack/run-example.sh | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index ff86baf566..7d7f7822b6 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -21,6 +21,7 @@ case "$iac_binary" in exit 64 ;; esac +microvm_foundation_default_tfvars=false case "$example" in base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation) @@ -70,6 +71,9 @@ lockfile_existed=false if [ "$use_tfvars" = true ]; then if [ -z "$tfvars_file" ]; then tfvars_file="$script_dir/$example.tfvars" + if [ "$example" = microvm-foundation ]; then + microvm_foundation_default_tfvars=true + fi fi case "$tfvars_file" in @@ -82,6 +86,7 @@ if [ "$use_tfvars" = true ]; then echo "Pass it as the third argument or set MINISTACK_TFVARS_FILE." >&2 exit 66 fi + fi lambda_fixture_dir="" @@ -89,6 +94,7 @@ lambda_created_paths="" ami_created_ids="" ssm_created_names="" override_created_paths="" +tfvars_created_paths="" lambda_zip_paths=" $source_root/lambdas/functions/ami-housekeeper/ami-housekeeper.zip $source_root/lambdas/functions/control-plane/runners.zip @@ -106,6 +112,10 @@ cleanup() { rm -f "$override_file" done + for fixture_file in $tfvars_created_paths; do + rm -f "$fixture_file" + done + for name in $ssm_created_names; do ministack_aws ssm delete-parameter --name "$name" >/dev/null 2>&1 || true done @@ -217,6 +227,48 @@ create_ssm_fixture() { $name" } +create_microvm_foundation_fixture() { + vpc_id=$(ministack_aws ec2 describe-vpcs \ + --filters Name=is-default,Values=true \ + --query 'Vpcs[0].VpcId' \ + --output text) + subnet_id=$(ministack_aws ec2 describe-subnets \ + --filters "Name=vpc-id,Values=$vpc_id" "Name=state,Values=available" \ + --query 'Subnets[0].SubnetId' \ + --output text) + + case "$vpc_id" in + vpc-[0-9a-f]*) ;; + *) + echo "MiniStack default VPC fixture was not found." >&2 + exit 70 + ;; + esac + + case "$subnet_id" in + subnet-[0-9a-f]*) ;; + *) + echo "MiniStack default subnet fixture was not found." >&2 + exit 70 + ;; + esac + + fixture_tfvars=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-foundation.XXXXXX") + printf '%s\n' \ + "aws_region = \"$AWS_DEFAULT_REGION\"" \ + '' \ + 'network_connectors = {' \ + ' ministack = {' \ + ' name = "ministack"' \ + " vpc_id = \"$vpc_id\"" \ + " subnet_ids = [\"$subnet_id\"]" \ + ' }' \ + '}' > "$fixture_tfvars" + tfvars_created_paths="$tfvars_created_paths +$fixture_tfvars" + tfvars_file="$fixture_tfvars" +} + create_ami_override() { override_file="$example_root/zz_ministack_ami_override.tf" printf '%s\n' \ @@ -280,6 +332,10 @@ create_ministack_fixtures() { wait_for_ministack + if [ "$microvm_foundation_default_tfvars" = true ]; then + create_microvm_foundation_fixture + fi + lambda_fixture_dir=$(mktemp -d "${TMPDIR:-/tmp}/terraform-aws-github-runner-ministack-lambda.XXXXXX") printf '%s\n' 'exports.handler = async () => ({ statusCode: 200, body: "ministack" });' > "$lambda_fixture_dir/index.js" (CDPATH='' cd -- "$lambda_fixture_dir" && zip -q ministack-lambda.zip index.js) From 79e0c808eba1061bac63ed7a81e7482aefafcd39 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 9 Sep 2026 11:24:42 +0200 Subject: [PATCH 16/54] ci(ministack): disable microvm foundation example --- .github/workflows/ministack.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index c28a249abf..5aec51a53c 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -72,7 +72,6 @@ jobs: - ephemeral - multi-runner - multi-runner-v2 - - microvm-foundation - termination-watcher services: ministack: From a4b088055a4247666b1b7ca64477535dc82915e0 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 11 Sep 2026 10:48:47 +0200 Subject: [PATCH 17/54] chore(examples): add OpenTofu foundation lock --- .../.terraform.lock.hcl.tofu | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/examples/microvm-foundation/.terraform.lock.hcl.tofu b/examples/microvm-foundation/.terraform.lock.hcl.tofu index 045ca37e02..0bb2113ed3 100644 --- a/examples/microvm-foundation/.terraform.lock.hcl.tofu +++ b/examples/microvm-foundation/.terraform.lock.hcl.tofu @@ -2,39 +2,39 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/aws" { - version = "6.63.0" + version = "6.64.0" constraints = ">= 6.61.0" hashes = [ - "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", - "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", - "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", - "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", - "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", - "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", - "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", - "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", - "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", - "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", - "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", - "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", - "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", - "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", - "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", - "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", - "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", - "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", - "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", - "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", - "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", - "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", - "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", - "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", - "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", - "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", - "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", - "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", - "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", - "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", + "h1:/G38+XhC1mBVkmeWdtk/wk7lX2BxviJ2XZ70dpoaKKQ=", + "h1:7BzHdGCBG5usqOIhfBq89dkdUopnSo+qe9qRCKDSRHc=", + "h1:8AgY9Hc5/R5j97WgCcDSlbuKk0pjk3vkp7oz4mtGVY8=", + "h1:DKdOy/0RfYLxpzAXBPWTO5Eusvqx5UoGxiq2J0E6DY4=", + "h1:KSwetpR4S2eUsKmHftt73Cbx72lPWYET4V+Ej05rnkI=", + "h1:MEi5Ecge1Uwx/DRGfdVDzV5Q/soRxKh6dBHxUjGdaDQ=", + "h1:VqjWicgPZW32+YnSe0Lo78qq8/24I8XNV+E9d/lBz/4=", + "h1:WBgbFHdg/3ekWoAH6UeKiwfk6iqLr1f7TX9R/mJUK8M=", + "h1:YisB3zMV5Kh6p5/eVuPAAPEmudD/UqGN4C/V3zRtAq4=", + "h1:bG5dXqR4mSlcebUG+anerOWYDyeaScZJeLSJk0cYBfE=", + "h1:iosW/imG2pc4La7qdeM/rK6ldMXhcU6YVW7tjqwNXtI=", + "h1:nKE1gnLZxIoqukQ1YI9EUdmrQIUeAN4PWb5ecN8U9K8=", + "h1:x0hJO5+On8FaKExr4p2cNJhWsNWFZq1EiDD6CfVwy2E=", + "h1:yPH75sRH+f3aJlJAloOL/BikeZV6/0GP8VQvnJoMRKM=", + "h1:zCWB5ZD98/ZC0a50HTGoC/fTAseh189xxCFEL5Mt7r4=", + "zh:06e09ced9480ae12578122f7a25758a15d8fe684da0f6a0a61b9bc2f4a4918ad", + "zh:2035805f0ed8bf81d493e7a52f22965b3d5d402687a95d1caa8c4b1b348c1264", + "zh:25fe72a3d6a330eab6c8957f9e6bdf297ffdce95fa059fef30b80da764bee6b2", + "zh:49df644d19e39b9947e84609260028687057191ddd941783c0211386ade53040", + "zh:4d8438a5d25f18eb376c8375c70f81afb79d0fc1e63ebb6df1d0e02287964dde", + "zh:5cd9717e819506132126a896e959cd4cf1bb213c033c37777c9d01a593937e2c", + "zh:6955caa4f435373ae870de31bdda85e51c60b68c51a4206df5a21b853bcefe21", + "zh:82a413500c35241745e097797610d2bff57c26e29f34ca711182fdde5c265d13", + "zh:831f78acce42a759a977769b0409387ec13ff64b4f46c24eb7e7662e0f352525", + "zh:88648a159119a0435bf86c6cd1f7482dc43dfa2eb742f9b29053da1ee9fdabd8", + "zh:9fc745d71a2e36dbdae0ee69be70675509e5a9dec1a3c5a9be6007e568d78c07", + "zh:bf6d11d6ed1655f61f70eb2906e5d1f7ff5e78b6539dcfb3116ed6f8960c9e20", + "zh:ca17a6ca363afe930ad3474966d39cb549b7f1e5efdca909972dd26f90eefc89", + "zh:d7e9cc87ada1314e6d8ecc5849385a8f8f45757bd2c145b8657f015c65e5078d", + "zh:eddb4d6d86700788d132ba2a83d306646ccb2a3a0cec0a0ab3e215307c61d8f2", ] } From 33204fef0151b5abb85ea37c1b9849ab4e02d331 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 12 Sep 2026 01:55:34 +0200 Subject: [PATCH 18/54] ci(ministack): enable microvm foundation lifecycle --- .github/workflows/ministack.yml | 1 + tests/ministack/run-example.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 5aec51a53c..c28a249abf 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -72,6 +72,7 @@ jobs: - ephemeral - multi-runner - multi-runner-v2 + - microvm-foundation - termination-watcher services: ministack: diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index 7d7f7822b6..58d4409c25 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -81,7 +81,7 @@ if [ "$use_tfvars" = true ]; then *) tfvars_file="$PWD/$tfvars_file" ;; esac - if [ ! -f "$tfvars_file" ]; then + if [ ! -f "$tfvars_file" ] && [ "$microvm_foundation_default_tfvars" != true ]; then echo "Terraform variables file not found: $tfvars_file" >&2 echo "Pass it as the third argument or set MINISTACK_TFVARS_FILE." >&2 exit 66 From 7cbfe1e3425445e2f9fa25288b473d1f1da87d12 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 17:12:05 +0200 Subject: [PATCH 19/54] fix(multi-runner): make v2 inputs independent --- modules/multi-runner/main.tf | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 23d51b7f65..3308b7bb10 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -31,4 +31,17 @@ resource "random_string" "random" { length = 24 special = false upper = false + + lifecycle { + precondition { + condition = local.use_v2_config || ( + 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; v2 configuration supplies these through experimental inputs." + } + } } From b2ee8bb1da56fee92b1bbd4f5f122476abf9f137 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 17:21:03 +0200 Subject: [PATCH 20/54] fix(multi-runner): validate v1 and v2 inputs --- modules/multi-runner/main.tf | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 3308b7bb10..23d51b7f65 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -31,17 +31,4 @@ resource "random_string" "random" { length = 24 special = false upper = false - - lifecycle { - precondition { - condition = local.use_v2_config || ( - 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; v2 configuration supplies these through experimental inputs." - } - } } From e686ceab89a97aef222586e026e170e3b181b77b Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 21:25:45 +0200 Subject: [PATCH 21/54] test(ministack): run microvm example --- .github/workflows/ministack.yml | 1 + tests/ministack/microvm.tfvars | 21 ++++++++++++++ tests/ministack/run-example.sh | 51 +++++++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 tests/ministack/microvm.tfvars diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index c28a249abf..b4914914e8 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -73,6 +73,7 @@ jobs: - multi-runner - multi-runner-v2 - microvm-foundation + - microvm - termination-watcher services: ministack: diff --git a/tests/ministack/microvm.tfvars b/tests/ministack/microvm.tfvars new file mode 100644 index 0000000000..b83956b548 --- /dev/null +++ b/tests/ministack/microvm.tfvars @@ -0,0 +1,21 @@ +aws_region = "eu-west-1" +environment = "microvm-ministack" + +github_app = { + key_base64_ssm = { + name = "/ministack/microvm/github-app-key" + arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/microvm/github-app-key" + } + id_ssm = { + name = "/ministack/microvm/github-app-id" + arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/microvm/github-app-id" + } + webhook_secret_ssm = { + name = "/ministack/microvm/webhook-secret" + arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/microvm/webhook-secret" + } +} + +lambda_artifact_bucket = "github-actions-runner-microvm-ministack" +microvm_image_arn = "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" +egress_network_connector_arn = "arn:aws:lambda:eu-west-1:000000000000:network-connector:ministack" diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index 58d4409c25..232b37ff5f 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -24,14 +24,14 @@ esac microvm_foundation_default_tfvars=false case "$example" in - base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation) + base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation | microvm) use_tfvars=true ;; termination-watcher) use_tfvars=false ;; *) - echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, microvm-foundation, termination-watcher" >&2 + echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, microvm-foundation, microvm, termination-watcher" >&2 exit 64 ;; esac @@ -39,7 +39,7 @@ esac case "$action" in init | plan | apply | destroy) ;; *) - echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|microvm-foundation|termination-watcher} [TFVARS_FILE]" >&2 + echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|microvm-foundation|microvm|termination-watcher} [TFVARS_FILE]" >&2 exit 64 ;; esac @@ -93,6 +93,7 @@ lambda_fixture_dir="" lambda_created_paths="" ami_created_ids="" ssm_created_names="" +s3_created_buckets="" override_created_paths="" tfvars_created_paths="" lambda_zip_paths=" @@ -120,6 +121,12 @@ cleanup() { ministack_aws ssm delete-parameter --name "$name" >/dev/null 2>&1 || true done + for bucket in $s3_created_buckets; do + ministack_aws s3api delete-object --bucket "$bucket" --key runners.zip >/dev/null 2>&1 || true + ministack_aws s3api delete-object --bucket "$bucket" --key webhook.zip >/dev/null 2>&1 || true + ministack_aws s3api delete-bucket --bucket "$bucket" >/dev/null 2>&1 || true + done + for image_id in $ami_created_ids; do ministack_aws ec2 deregister-image --image-id "$image_id" >/dev/null 2>&1 || true done @@ -269,6 +276,25 @@ $fixture_tfvars" tfvars_file="$fixture_tfvars" } +create_s3_fixture() { + bucket="$1" + key="$2" + file="$3" + + if ! ministack_aws s3api head-bucket --bucket "$bucket" >/dev/null 2>&1; then + ministack_aws s3api create-bucket \ + --bucket "$bucket" \ + --create-bucket-configuration LocationConstraint="$AWS_DEFAULT_REGION" >/dev/null + s3_created_buckets="$s3_created_buckets +$bucket" + fi + + ministack_aws s3api put-object \ + --bucket "$bucket" \ + --key "$key" \ + --body "$file" >/dev/null +} + create_ami_override() { override_file="$example_root/zz_ministack_ami_override.tf" printf '%s\n' \ @@ -373,6 +399,25 @@ $lambda_zip" create_ami_fixture "ministack-v2-linux-x64" x86_64 >/dev/null create_ami_fixture "ministack-v2-windows-x64" x86_64 >/dev/null ;; + microvm) + create_ssm_fixture \ + "/ministack/microvm/github-app-key" \ + "test-only" + create_ssm_fixture \ + "/ministack/microvm/github-app-id" \ + "123456" + create_ssm_fixture \ + "/ministack/microvm/webhook-secret" \ + "test-only" + create_s3_fixture \ + "github-actions-runner-microvm-ministack" \ + "runners.zip" \ + "$lambda_fixture_dir/ministack-lambda.zip" + create_s3_fixture \ + "github-actions-runner-microvm-ministack" \ + "webhook.zip" \ + "$lambda_fixture_dir/ministack-lambda.zip" + ;; esac } From dce9c9914f7e5baaf64cb0429f957c1e8f4df153 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 23:50:23 +0200 Subject: [PATCH 22/54] Revert "docs(adr): clarify runner-config boundary" This reverts commit 182149eed3362f2b1dad6d6e70eacce939194d28. --- ...-runner-orchestration-provider-boundary.md | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index 06dc16a875..3d77e2f3cd 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -229,36 +229,6 @@ not below `modules/runner-config`. This keeps the common composition module small and prevents provider-owned resources from becoming part of the common contract. -### `runner-config` is the provider-neutral composition boundary - -`modules/runner-config` is an internal composition module selected by -`multi-runner`; it is not a standalone public entry point. It receives one -resolved runner configuration and owns the common runner identity, IAM role, -runner bootstrap parameters, SSM housekeeper composition, and the capability -connections between the selected providers. - -`runner-config` dispatches exactly one typed orchestration provider and one -typed compute provider. Provider selection is made from the plan-known typed -wrappers, not from a string discriminator or runtime fallback. The selected -provider receives the resolved common runner settings and returns only the -provider-specific resources, environment variables, IAM fragments, and -outputs required by the orchestration provider. - -Webhook queues, Lambda functions, schedules, and retry behavior remain owned -by the webhook orchestration provider. EC2 instances, Lambda MicroVM capacity, -image publication, and provider-specific bootstrap behavior remain owned by -their compute providers. `runner-config` connects these capabilities but does -not absorb either provider's implementation. - -For Lambda MicroVM runners, the image is an immutable runtime artifact. The -runner configuration and its sensitive, short-lived bootstrap value are -published through the runner-config SSM contract and retrieved when the -MicroVM starts. Tenant-specific runner configuration, registration tokens, and -JIT payloads must not be baked into the image or its Terraform configuration. -The image therefore supplies the runner and lifecycle-hook runtime, while the -selected compute provider supplies the lane-specific SSM path and execution -permissions. - ```mermaid flowchart TD Multi["multi-runner: translate and resolve"] --> Config["runner-config: compose one runner config"] From efb3fea2b1361afe0299042afa1dab52c2b82a53 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 11 Sep 2026 10:55:49 +0200 Subject: [PATCH 23/54] chore(examples): add OpenTofu MicroVM lock --- examples/microvm/.terraform.lock.hcl.tofu | 124 +++++++++++----------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/examples/microvm/.terraform.lock.hcl.tofu b/examples/microvm/.terraform.lock.hcl.tofu index 7aa235531d..72e73d566d 100644 --- a/examples/microvm/.terraform.lock.hcl.tofu +++ b/examples/microvm/.terraform.lock.hcl.tofu @@ -2,76 +2,76 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/aws" { - version = "6.63.0" + version = "6.64.0" constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" hashes = [ - "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", - "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", - "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", - "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", - "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", - "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", - "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", - "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", - "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", - "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", - "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", - "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", - "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", - "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", - "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", - "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", - "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", - "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", - "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", - "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", - "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", - "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", - "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", - "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", - "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", - "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", - "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", - "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", - "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", - "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", + "h1:/G38+XhC1mBVkmeWdtk/wk7lX2BxviJ2XZ70dpoaKKQ=", + "h1:7BzHdGCBG5usqOIhfBq89dkdUopnSo+qe9qRCKDSRHc=", + "h1:8AgY9Hc5/R5j97WgCcDSlbuKk0pjk3vkp7oz4mtGVY8=", + "h1:DKdOy/0RfYLxpzAXBPWTO5Eusvqx5UoGxiq2J0E6DY4=", + "h1:KSwetpR4S2eUsKmHftt73Cbx72lPWYET4V+Ej05rnkI=", + "h1:MEi5Ecge1Uwx/DRGfdVDzV5Q/soRxKh6dBHxUjGdaDQ=", + "h1:VqjWicgPZW32+YnSe0Lo78qq8/24I8XNV+E9d/lBz/4=", + "h1:WBgbFHdg/3ekWoAH6UeKiwfk6iqLr1f7TX9R/mJUK8M=", + "h1:YisB3zMV5Kh6p5/eVuPAAPEmudD/UqGN4C/V3zRtAq4=", + "h1:bG5dXqR4mSlcebUG+anerOWYDyeaScZJeLSJk0cYBfE=", + "h1:iosW/imG2pc4La7qdeM/rK6ldMXhcU6YVW7tjqwNXtI=", + "h1:nKE1gnLZxIoqukQ1YI9EUdmrQIUeAN4PWb5ecN8U9K8=", + "h1:x0hJO5+On8FaKExr4p2cNJhWsNWFZq1EiDD6CfVwy2E=", + "h1:yPH75sRH+f3aJlJAloOL/BikeZV6/0GP8VQvnJoMRKM=", + "h1:zCWB5ZD98/ZC0a50HTGoC/fTAseh189xxCFEL5Mt7r4=", + "zh:06e09ced9480ae12578122f7a25758a15d8fe684da0f6a0a61b9bc2f4a4918ad", + "zh:2035805f0ed8bf81d493e7a52f22965b3d5d402687a95d1caa8c4b1b348c1264", + "zh:25fe72a3d6a330eab6c8957f9e6bdf297ffdce95fa059fef30b80da764bee6b2", + "zh:49df644d19e39b9947e84609260028687057191ddd941783c0211386ade53040", + "zh:4d8438a5d25f18eb376c8375c70f81afb79d0fc1e63ebb6df1d0e02287964dde", + "zh:5cd9717e819506132126a896e959cd4cf1bb213c033c37777c9d01a593937e2c", + "zh:6955caa4f435373ae870de31bdda85e51c60b68c51a4206df5a21b853bcefe21", + "zh:82a413500c35241745e097797610d2bff57c26e29f34ca711182fdde5c265d13", + "zh:831f78acce42a759a977769b0409387ec13ff64b4f46c24eb7e7662e0f352525", + "zh:88648a159119a0435bf86c6cd1f7482dc43dfa2eb742f9b29053da1ee9fdabd8", + "zh:9fc745d71a2e36dbdae0ee69be70675509e5a9dec1a3c5a9be6007e568d78c07", + "zh:bf6d11d6ed1655f61f70eb2906e5d1f7ff5e78b6539dcfb3116ed6f8960c9e20", + "zh:ca17a6ca363afe930ad3474966d39cb549b7f1e5efdca909972dd26f90eefc89", + "zh:d7e9cc87ada1314e6d8ecc5849385a8f8f45757bd2c145b8657f015c65e5078d", + "zh:eddb4d6d86700788d132ba2a83d306646ccb2a3a0cec0a0ab3e215307c61d8f2", ] } provider "registry.opentofu.org/hashicorp/null" { - version = "3.3.1" + version = "3.3.2" constraints = "~> 3.0, ~> 3.2" hashes = [ - "h1:2wld81FnmHW0WVgy081sIfokCr2+NuatS8yjeLEet7Y=", - "h1:AClQjJ6X22V4qcRgcYSxiXCMmp2pz0G8WVQC7wAx66o=", - "h1:AY3XQbuviNd2X5VhHYEbhNta1m/CG3JD2BKFKhCt1Y4=", - "h1:CUOZUd7H11lsU+4tISlnYIiP5BqnX8IDwFCVqfLJyAg=", - "h1:JIfV0nA/pLWnIFGscvTfuavQCn2NeHxJBeb6UUg/joA=", - "h1:RejAh+nyCwqDGExGln2Kb4Ro5LyHak0eJe0P9g8CHPc=", - "h1:SHOuTZjYymsmy4asuRq6NC3yW+zdVZOOt4f5nrb+EPM=", - "h1:WwPat/gT4gO8GvvKNdSkkXWVD65JppLJfqKOt9HhOqQ=", - "h1:Z3hXVLrOyaRiiLmmL5UCOdcRMguwjN1x5TYNdmBDgls=", - "h1:dd78Ad5HdfPzPts7A9qIxfitXhAriV/qza38fr2ukjk=", - "h1:dyVb++KwDdybzLTE6bf7GZiVQ31iWsgKPWmhTQ8G42k=", - "h1:gD8ZH6WWe+5gg5+y8SpLWGPUDzSxcQ3HKP8IDM/wW3I=", - "h1:juXCww0zRQKFTDZoKqYR0+Sn1lu99oeL6pr0Jh6LWx0=", - "h1:kFAySmtsshyNV7IhIrEdASzVcvwy68eeZCVC66P7yNk=", - "h1:nS5azDopRisB2NInwDx3Hrfg2FdVt8Gw0gTQzC0rd70=", - "zh:164eb061d84e01759f391265865fb31828083d0a06b25f7af7e094cbdb18c799", - "zh:1bb9b669a82b52c0cba2860c71e9ee6699ef302f28cb8ed06f572d39bc6c7c4f", - "zh:1ea9b31a8f29302122c1e8d673693f3ac270336dae560af803cd1117265a469a", - "zh:238bd463cb0154fb935dc331da40c0a9cbe5db9cee615ae5f35ccad5eed7dc41", - "zh:30ef2b7384cf7e20f33fe75754b54cf669d59816f3ad4fc73bfb2b26fb6735e9", - "zh:35b5cded16e4b57c207d03ee0979b14baf486fa520e6edb7a2eecf18f1b85471", - "zh:3dc840d13a50cd215c7540573f27e2b61f739ba90aee5b7c3846079aa0ab5534", - "zh:3f9309a18db608f975d5691fcb47a6e14d77199156a52e9c39dcafe3737f2b07", - "zh:44263a219f7dbd1848b545d080110b4f7d0495e77b71cd3c7a0b5ec52a09accb", - "zh:4dec54aa5f445eeea035bbd4839bcded5e47ecd07cba0e70c5a09e9272cb592f", - "zh:5e8fb319d7c6d6c4566a18b9d0c91580b4901a96acd7fdc476bfc79f074368e2", - "zh:b0e8b6d41834b57fcfbb5ca00da52ccb757e1a95b6a2d546c0dae8bfbeca1cdf", - "zh:bbde4c3a1dcc1718027a61a4cdf661619d17af1b58df1038fe27bcf43c3dc29b", - "zh:c4140fff9f692baf29236557f706f9515f93229413438527d764023a82301da3", - "zh:f8e9d83184e4bbeb97c6f0d569833007c48ba5a7ff334def201df4991d03a962", + "h1:1T+00cjQNmRAHAz9xjEBFpf5wRRb0IBuXS/W8ke5BWs=", + "h1:46gmIYe+klib6TlHKSqEkMLjvnzVWiCB2NYA2zR8MX8=", + "h1:7WQ3wjfaeqnXxq+a8cYiYeWUnMTgY1JcuX+z7sZd72s=", + "h1:MVM+vkVtW/YyKfn111pyho0y87I4TekaNMbBLkn0/C8=", + "h1:QBcIbI2Dp4v6Iui37pn4qmw8YeiFLbSWcJuzZVl/65Y=", + "h1:SsVKTUR+vgLaC1YnoDa2fnYpzREcgNgWRcu5x+vwjHA=", + "h1:WUaeuTNn9w6UXZ9cMq4+qZy4ZAr71B9NcUDEXjqfdKs=", + "h1:WtEaA7alasNwEQ4L3+KyQtbkSOPsexzJ4LUZ7PKaycI=", + "h1:WxS7rjYIZ1WQc4GkICch8XrbxoSY8TjUfLbPDo6oEcQ=", + "h1:Ysvc/FPvcwk+iMg7IcLkqZhT/KhtZTYji+UBqMlcTs4=", + "h1:ZLjbXnfVcRvS/DAN3BcNebOsnOcs3Nx6mJpFCj4dZ2c=", + "h1:fAmvQjIGyqdGMc+v/fUEINCyuU4iaKSzsV8PWsOnmAc=", + "h1:jwkbEtf3S7W+Bl4soynNkUHFfK/4I/H74urHY758XVw=", + "h1:qY1sKzlxNTp/dqZR23bM4egmVMRlaQudLlBYraMt1pw=", + "h1:t8H1KNwJQwKE/GqpHeRxOWgMk0Yv35qbBTBzqB/rhr0=", + "zh:09e94b0b7dfc0c6450c247517b5410546039c758e513d89b588af6df70c3d57d", + "zh:0b72497b6fd79a2b04785b64890a565a8cc7b06ded95da05e6dab2f3b8a02d58", + "zh:1c0ee6f81f7bcdec8d568a145a450eb57a6f1cfe5e48943375d1af22ed54151e", + "zh:1c6899b475f035d352af1e7f33dc30beab8b8e3784f8cb55a2cc4a11997fbd66", + "zh:43e57a2a56e9874604501bdebe431bb573fb77d2c5f4d7598ab30727dc0e90ea", + "zh:49cf2f36298a5ac3ac8d80ceb87466e6a99d2c021005bcd9e3a79f2314fd0a13", + "zh:665be40d2c7f3d768b8f39a041371526d4b7b396b4c11e162a1886212da176c9", + "zh:6bb1583d88ddb38b1c6b4624e25ad414ddd8bf65b0dd9c074580847311f83924", + "zh:71d64453bdc795667e9841d7c90e3fef6ff157e0598d51dac4bb1e4583b85407", + "zh:73ac02bc3b680e1ea75aab24ec2a359c8f0a021f73d43b2feae0a899e75a93ad", + "zh:b2b777ee07b910e7345df85321fab6c9a25c33ecb56129758dda8fadaba09fe4", + "zh:bb984d52880749a49e509e3b243804869e1af40ee2d34322a30f5f340f8d8dbd", + "zh:e0724bc083527343b4a4099fd4f95511e49a0e113416cdba58a64446742b68b1", + "zh:e391c14e367cd64d986ddc8d81f2db76d49600ce0521720618ff1ecc0decde7f", + "zh:e95c1af8e8e9967d678cfa7c77ca229c863a68f7c9a85318bf08f26628afe338", ] } From ddf31ea872266607b405cb73d180e0b6b277bbcc Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 6 Aug 2026 20:41:02 +0200 Subject: [PATCH 24/54] feat(compute-providers): add MicroVM API foundations --- .../microvm/src/control-plane/config.test.ts | 71 ++++ .../aws/microvm/src/control-plane/config.ts | 88 +++++ .../src/control-plane/microvms.test.ts | 311 ++++++++++++++++++ .../aws/microvm/src/control-plane/microvms.ts | 223 +++++++++++++ .../aws/microvm/src/environment.d.ts | 15 + lambdas/libs/compute-providers/package.json | 1 + .../libs/compute-providers/provider-types.ts | 2 +- lambdas/yarn.lock | 291 +++++++++++++++- 8 files changed, 1000 insertions(+), 2 deletions(-) create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts new file mode 100644 index 0000000000..ce692a1ab4 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { loadMicrovmProviderConfig } from './config'; + +const cleanEnv = process.env; + +beforeEach(() => { + process.env = { ...cleanEnv }; + process.env.MICROVM_IMAGE_ARN = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; + process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/microvm-runner'; + delete process.env.MICROVM_IMAGE_VERSION; + delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS; + delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS; + delete process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS; + delete process.env.MICROVM_LOG_GROUP; +}); + +describe('loadMicrovmProviderConfig', () => { + it('loads required values and applies optional defaults', () => { + expect(loadMicrovmProviderConfig()).toEqual({ + imageIdentifier: process.env.MICROVM_IMAGE_ARN, + imageVersion: undefined, + executionRoleArn: process.env.MICROVM_EXECUTION_ROLE_ARN, + ingressNetworkConnectors: undefined, + egressNetworkConnectors: undefined, + maximumDurationInSeconds: 3600, + logging: undefined, + }); + }); + + it('loads versions, logging, duration, and either connector list format', () => { + process.env.MICROVM_IMAGE_VERSION = ' 3.0 '; + process.env.MICROVM_INGRESS_NETWORK_CONNECTORS = '["arn:ingress:one","arn:ingress:two"]'; + process.env.MICROVM_EGRESS_NETWORK_CONNECTORS = 'arn:egress:one, arn:egress:two'; + process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS = '1200'; + process.env.MICROVM_LOG_GROUP = ' /aws/lambda-microvms/runner '; + + expect(loadMicrovmProviderConfig()).toMatchObject({ + imageVersion: '3.0', + ingressNetworkConnectors: ['arn:ingress:one', 'arn:ingress:two'], + egressNetworkConnectors: ['arn:egress:one', 'arn:egress:two'], + maximumDurationInSeconds: 1200, + logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, + }); + }); + + it.each([ + ['MICROVM_IMAGE_ARN', 'MICROVM_IMAGE_ARN'], + ['MICROVM_EXECUTION_ROLE_ARN', 'MICROVM_EXECUTION_ROLE_ARN'], + ])('requires %s', (environmentVariable, expectedName) => { + delete process.env[environmentVariable]; + + expect(() => loadMicrovmProviderConfig()).toThrow( + `${expectedName} must be configured for the MicroVM compute provider`, + ); + }); + + it.each(['0', '28801', '1.5', 'invalid'])('rejects invalid maximum duration %s', (duration) => { + process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS = duration; + + expect(() => loadMicrovmProviderConfig()).toThrow( + 'MICROVM_MAXIMUM_DURATION_IN_SECONDS must be an integer between 1 and 28800', + ); + }); + + it.each(['[not-json', '[]', '["valid", 2]', 'first,'])('rejects malformed connector lists %s', (connectors) => { + process.env.MICROVM_EGRESS_NETWORK_CONNECTORS = connectors; + + expect(() => loadMicrovmProviderConfig()).toThrow(/MICROVM_EGRESS_NETWORK_CONNECTORS must/); + }); +}); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts new file mode 100644 index 0000000000..7c0a7662b9 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts @@ -0,0 +1,88 @@ +import type { Logging, RunMicrovmCommandInput } from '@aws-sdk/client-lambda-microvms'; + +const DEFAULT_MAXIMUM_DURATION_IN_SECONDS = 3600; +const MAXIMUM_DURATION_IN_SECONDS = 28800; + +export interface MicrovmProviderConfig { + egressNetworkConnectors?: string[]; + executionRoleArn: string; + imageIdentifier: string; + imageVersion?: string; + ingressNetworkConnectors?: string[]; + logging?: Logging; + maximumDurationInSeconds: number; +} + +function requiredEnvironmentValue(name: string, value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) { + throw new Error(`${name} must be configured for the MicroVM compute provider`); + } + return trimmed; +} + +function optionalEnvironmentValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function parseNetworkConnectors(name: string, value: string | undefined): string[] | undefined { + const configuredValue = optionalEnvironmentValue(value); + if (!configuredValue) return undefined; + + let connectors: unknown; + try { + connectors = configuredValue.startsWith('[') + ? JSON.parse(configuredValue) + : configuredValue.split(',').map((connector) => connector.trim()); + } catch (error) { + throw new Error(`${name} must be a JSON array or comma-separated list`, { cause: error }); + } + + if ( + !Array.isArray(connectors) || + connectors.length === 0 || + connectors.some((connector) => typeof connector !== 'string' || connector.trim().length === 0) + ) { + throw new Error(`${name} must contain one or more non-empty connector ARNs`); + } + + return connectors.map((connector) => connector.trim()); +} + +function parseMaximumDuration(value: string | undefined): number { + if (!optionalEnvironmentValue(value)) return DEFAULT_MAXIMUM_DURATION_IN_SECONDS; + + const maximumDurationInSeconds = Number(value); + if ( + !Number.isInteger(maximumDurationInSeconds) || + maximumDurationInSeconds < 1 || + maximumDurationInSeconds > MAXIMUM_DURATION_IN_SECONDS + ) { + throw new Error( + `MICROVM_MAXIMUM_DURATION_IN_SECONDS must be an integer between 1 and ${MAXIMUM_DURATION_IN_SECONDS}`, + ); + } + + return maximumDurationInSeconds; +} + +export function loadMicrovmProviderConfig(): MicrovmProviderConfig { + const logGroup = optionalEnvironmentValue(process.env.MICROVM_LOG_GROUP); + + return { + imageIdentifier: requiredEnvironmentValue('MICROVM_IMAGE_ARN', process.env.MICROVM_IMAGE_ARN), + imageVersion: optionalEnvironmentValue(process.env.MICROVM_IMAGE_VERSION), + executionRoleArn: requiredEnvironmentValue('MICROVM_EXECUTION_ROLE_ARN', process.env.MICROVM_EXECUTION_ROLE_ARN), + ingressNetworkConnectors: parseNetworkConnectors( + 'MICROVM_INGRESS_NETWORK_CONNECTORS', + process.env.MICROVM_INGRESS_NETWORK_CONNECTORS, + ), + egressNetworkConnectors: parseNetworkConnectors( + 'MICROVM_EGRESS_NETWORK_CONNECTORS', + process.env.MICROVM_EGRESS_NETWORK_CONNECTORS, + ), + maximumDurationInSeconds: parseMaximumDuration(process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS), + logging: logGroup ? ({ cloudWatch: { logGroup } } satisfies RunMicrovmCommandInput['logging']) : undefined, + }; +} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts new file mode 100644 index 0000000000..7d7199a3cc --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts @@ -0,0 +1,311 @@ +import { + LambdaMicrovmsClient, + ListMicrovmsCommand, + ListTagsCommand, + RunMicrovmCommand, + TagResourceCommand, + TerminateMicrovmCommand, + UntagResourceCommand, +} from '@aws-sdk/client-lambda-microvms'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MicrovmProviderConfig } from './config'; +import { + isRetryableMicrovmError, + listMicrovmRunners, + microvmArn, + microvmBootTimeExceeded, + runMicrovmRunner, + tagMicrovm, + terminateMicrovm, + untagMicrovm, +} from './microvms'; + +const mockMicrovmClient = mockClient(LambdaMicrovmsClient); +const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; +const config: MicrovmProviderConfig = { + imageIdentifier: imageArn, + imageVersion: '3.0', + executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', + egressNetworkConnectors: ['arn:egress'], + maximumDurationInSeconds: 1200, + logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, +}; + +beforeEach(() => { + mockMicrovmClient.reset(); + vi.useRealTimers(); + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_BOOT_TIME_IN_MINUTES = '5'; +}); + +describe('microvmArn', () => { + it('derives the MicroVM resource ARN from its image ARN', () => { + expect(microvmArn(imageArn, 'mvm-123')).toBe('arn:aws:lambda:eu-west-1:123456789012:microvm:mvm-123'); + expect(microvmArn(imageArn.replace('arn:aws:', 'arn:aws-us-gov:'), 'mvm-456')).toContain('arn:aws-us-gov:lambda:'); + }); + + it('rejects image names that cannot identify a customer MicroVM resource', () => { + expect(() => microvmArn('runner', 'mvm-123')).toThrow( + 'MICROVM_IMAGE_ARN is not a valid customer MicroVM image ARN', + ); + }); +}); + +describe('runMicrovmRunner', () => { + it('launches and tags a managed runner', async () => { + mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-123' }); + mockMicrovmClient.on(TagResourceCommand).resolves({}); + + await expect( + runMicrovmRunner({ + config, + environment: 'unit-test', + runHookPayload: '{"version":1}', + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'scale-up-lambda', + }), + ).resolves.toBe('mvm-123'); + + expect(mockMicrovmClient).toHaveReceivedCommandWith(RunMicrovmCommand, { + imageIdentifier: imageArn, + imageVersion: '3.0', + executionRoleArn: config.executionRoleArn, + egressNetworkConnectors: ['arn:egress'], + maximumDurationInSeconds: 1200, + logging: config.logging, + runHookPayload: '{"version":1}', + clientToken: expect.any(String), + }); + expect(mockMicrovmClient).toHaveReceivedCommandWith(TagResourceCommand, { + Resource: microvmArn(imageArn, 'mvm-123'), + Tags: { + 'ghr:Application': 'github-action-runner', + 'ghr:created_by': 'scale-up-lambda', + 'ghr:environment': 'unit-test', + 'ghr:Owner': 'Codertocat', + 'ghr:Type': 'Org', + }, + }); + }); + + it('rejects a launch response without an ID', async () => { + mockMicrovmClient.on(RunMicrovmCommand).resolves({}); + + await expect( + runMicrovmRunner({ + config, + environment: 'unit-test', + runHookPayload: '{}', + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'pool-lambda', + }), + ).rejects.toThrow('RunMicrovm returned no microvmId'); + }); + + it('terminates a new runner when required tags cannot be applied', async () => { + const tagError = new Error('tag failed'); + mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-untagged' }); + mockMicrovmClient.on(TagResourceCommand).rejects(tagError); + mockMicrovmClient.on(TerminateMicrovmCommand).resolves({}); + + await expect( + runMicrovmRunner({ + config, + environment: 'unit-test', + runHookPayload: '{}', + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'scale-up-lambda', + }), + ).rejects.toThrow('tag failed'); + + expect(mockMicrovmClient).toHaveReceivedCommandWith(TerminateMicrovmCommand, { + microvmIdentifier: 'mvm-untagged', + }); + }); + + it('preserves the tag error when cleanup also fails', async () => { + mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-untagged' }); + mockMicrovmClient.on(TagResourceCommand).rejects(new Error('tag failed')); + mockMicrovmClient.on(TerminateMicrovmCommand).rejects(new Error('terminate failed')); + + await expect( + runMicrovmRunner({ + config, + environment: 'unit-test', + runHookPayload: '{}', + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'scale-up-lambda', + }), + ).rejects.toThrow('tag failed'); + }); +}); + +describe('listMicrovmRunners', () => { + it('paginates active MicroVMs and filters them by management tags', async () => { + const startedAt = new Date('2026-08-06T10:00:00.000Z'); + mockMicrovmClient + .on(ListMicrovmsCommand) + .resolvesOnce({ + nextToken: 'page-2', + items: [ + { microvmId: 'mvm-managed', imageArn, imageVersion: '3.0', startedAt, state: 'RUNNING' }, + { microvmId: 'mvm-terminated', imageArn, imageVersion: '3.0', startedAt, state: 'TERMINATED' }, + ], + }) + .resolvesOnce({ + items: [{ microvmId: 'mvm-other', imageArn, imageVersion: '3.0', startedAt, state: 'PENDING' }], + }); + mockMicrovmClient + .on(ListTagsCommand) + .resolvesOnce({ + Tags: { + 'ghr:Application': 'github-action-runner', + 'ghr:environment': 'unit-test', + 'ghr:Owner': 'Codertocat', + 'ghr:Type': 'Org', + 'ghr:github_runner_id': '42', + 'ghr:bypass-removal': 'true', + }, + }) + .resolvesOnce({ Tags: { 'ghr:Application': 'another-application' } }); + + await expect( + listMicrovmRunners({ + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org', + }), + ).resolves.toEqual([ + { + id: 'mvm-managed', + imageArn, + launchTime: startedAt, + owner: 'Codertocat', + type: 'Org', + orphan: false, + githubRunnerId: '42', + bypassRemoval: true, + state: 'RUNNING', + }, + ]); + + expect(mockMicrovmClient).toHaveReceivedNthCommandWith(2, ListMicrovmsCommand, { + maxResults: 50, + nextToken: 'page-2', + }); + }); + + it('applies environment, owner, type, and orphan filters after loading tags', async () => { + mockMicrovmClient.on(ListMicrovmsCommand).resolves({ + items: [ + { + microvmId: 'mvm-filtered', + imageArn, + imageVersion: '3.0', + startedAt: new Date(), + state: 'SUSPENDED', + }, + ], + }); + mockMicrovmClient.on(ListTagsCommand).resolves({ + Tags: { + 'ghr:Application': 'github-action-runner', + 'ghr:environment': 'other', + 'ghr:Owner': 'Other', + 'ghr:Type': 'Repo', + }, + }); + + await expect(listMicrovmRunners({ environment: 'unit-test' })).resolves.toEqual([]); + await expect(listMicrovmRunners({ runnerOwner: 'Codertocat' })).resolves.toEqual([]); + await expect(listMicrovmRunners({ runnerType: 'Org' })).resolves.toEqual([]); + await expect(listMicrovmRunners({ orphan: true })).resolves.toEqual([]); + }); + + it('skips a MicroVM that terminates before its tags can be read', async () => { + const resourceNotFound = Object.assign(new Error('gone'), { name: 'ResourceNotFoundException' }); + mockMicrovmClient.on(ListMicrovmsCommand).resolves({ + items: [{ microvmId: 'mvm-gone', imageArn, imageVersion: '3.0', startedAt: new Date(), state: 'RUNNING' }], + }); + mockMicrovmClient.on(ListTagsCommand).rejects(resourceNotFound); + + await expect(listMicrovmRunners()).resolves.toEqual([]); + }); + + it('surfaces unexpected tag lookup failures', async () => { + mockMicrovmClient.on(ListMicrovmsCommand).resolves({ + items: [{ microvmId: 'mvm-error', imageArn, imageVersion: '3.0', startedAt: new Date(), state: 'RUNNING' }], + }); + mockMicrovmClient.on(ListTagsCommand).rejects(new Error('list tags failed')); + + await expect(listMicrovmRunners()).rejects.toThrow('list tags failed'); + }); +}); + +describe('MicroVM lifecycle helpers', () => { + it('tags, untags, and terminates a MicroVM', async () => { + mockMicrovmClient.on(TagResourceCommand).resolves({}); + mockMicrovmClient.on(UntagResourceCommand).resolves({}); + mockMicrovmClient.on(TerminateMicrovmCommand).resolves({}); + + await tagMicrovm(imageArn, 'mvm-123', { key: 'value' }); + await untagMicrovm(imageArn, 'mvm-123', ['key']); + await terminateMicrovm('mvm-123'); + + expect(mockMicrovmClient).toHaveReceivedCommandWith(TagResourceCommand, { + Resource: microvmArn(imageArn, 'mvm-123'), + Tags: { key: 'value' }, + }); + expect(mockMicrovmClient).toHaveReceivedCommandWith(UntagResourceCommand, { + Resource: microvmArn(imageArn, 'mvm-123'), + TagKeys: ['key'], + }); + expect(mockMicrovmClient).toHaveReceivedCommandWith(TerminateMicrovmCommand, { + microvmIdentifier: 'mvm-123', + }); + }); + + it('evaluates the configured boot window', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-06T10:10:00.000Z')); + + expect(microvmBootTimeExceeded({})).toBe(false); + expect(microvmBootTimeExceeded({ launchTime: new Date('2026-08-06T10:06:00.000Z') })).toBe(false); + expect(microvmBootTimeExceeded({ launchTime: new Date('2026-08-06T10:04:00.000Z') })).toBe(true); + }); +}); + +describe('isRetryableMicrovmError', () => { + it.each(['ConflictException', 'InternalServerException', 'ServiceQuotaExceededException', 'ThrottlingException'])( + 'classifies %s as retryable', + (name) => { + expect(isRetryableMicrovmError(Object.assign(new Error(name), { name }))).toBe(true); + }, + ); + + it('classifies server, throttling, network, and nested failures as retryable', () => { + expect(isRetryableMicrovmError(Object.assign(new Error('server'), { $fault: 'server' }))).toBe(true); + expect(isRetryableMicrovmError(Object.assign(new Error('throttle'), { $metadata: { httpStatusCode: 429 } }))).toBe( + true, + ); + expect(isRetryableMicrovmError(Object.assign(new Error('network'), { code: 'ECONNRESET' }))).toBe(true); + expect( + isRetryableMicrovmError( + Object.assign(new Error('outer'), { cause: Object.assign(new Error(), { code: 'ETIMEDOUT' }) }), + ), + ).toBe(true); + }); + + it('does not retry configuration, unknown, or non-error failures', () => { + expect(isRetryableMicrovmError(Object.assign(new Error('invalid'), { name: 'ValidationException' }))).toBe(false); + expect(isRetryableMicrovmError(new Error('unknown'))).toBe(false); + expect(isRetryableMicrovmError('failure')).toBe(false); + }); +}); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts new file mode 100644 index 0000000000..edc4a3775b --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -0,0 +1,223 @@ +import { randomUUID } from 'node:crypto'; + +import { createChildLogger, getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; +import { + LambdaMicrovmsClient, + ListMicrovmsCommand, + ListTagsCommand, + RunMicrovmCommand, + TagResourceCommand, + TerminateMicrovmCommand, + UntagResourceCommand, +} from '@aws-sdk/client-lambda-microvms'; +import type { MicrovmItem, MicrovmState, RunMicrovmCommandInput } from '@aws-sdk/client-lambda-microvms'; + +import type { LambdaRunnerSource, ListRunnerFilters, RunnerInfo, RunnerType } from '../../../../core'; +import type { MicrovmProviderConfig } from './config'; + +const logger = createChildLogger('microvm-runners'); + +const APPLICATION_TAG = 'ghr:Application'; +const APPLICATION_TAG_VALUE = 'github-action-runner'; +const ACTIVE_STATES = new Set(['PENDING', 'RUNNING', 'SUSPENDING', 'SUSPENDED']); + +export interface MicrovmRunnerInfo extends RunnerInfo { + imageArn?: string; + state?: MicrovmState; +} + +export interface RunMicrovmRunnerInput { + config: MicrovmProviderConfig; + environment: string; + runHookPayload: string; + runnerOwner: string; + runnerType: RunnerType; + source: LambdaRunnerSource; +} + +interface AwsErrorLike extends Error { + cause?: unknown; + code?: string; + $fault?: 'client' | 'server'; + $metadata?: { httpStatusCode?: number }; +} + +const RETRYABLE_ERROR_NAMES = new Set([ + 'ConflictException', + 'InternalServerException', + 'RequestTimeout', + 'RequestTimeoutException', + 'ResourceConflictException', + 'ServiceException', + 'ServiceQuotaExceededException', + 'Throttling', + 'ThrottlingException', + 'TooManyRequestsException', +]); + +const RETRYABLE_NETWORK_ERROR_CODES = new Set([ + 'EAI_AGAIN', + 'ECONNREFUSED', + 'ECONNRESET', + 'ENETUNREACH', + 'ENOTFOUND', + 'ETIMEDOUT', +]); + +function microvmClient(): LambdaMicrovmsClient { + return getTracedAWSV3Client(new LambdaMicrovmsClient({ region: process.env.AWS_REGION })); +} + +export function microvmArn(imageArn: string, microvmId: string): string { + const match = /^arn:([^:]+):lambda:([^:]+):([0-9]{12}):microvm-image:.+$/.exec(imageArn); + if (!match) { + throw new Error(`MICROVM_IMAGE_ARN is not a valid customer MicroVM image ARN: ${imageArn}`); + } + + const [, partition, region, accountId] = match; + return `arn:${partition}:lambda:${region}:${accountId}:microvm:${microvmId}`; +} + +export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { + const commandInput: RunMicrovmCommandInput = { + imageIdentifier: input.config.imageIdentifier, + imageVersion: input.config.imageVersion, + executionRoleArn: input.config.executionRoleArn, + ingressNetworkConnectors: input.config.ingressNetworkConnectors, + egressNetworkConnectors: input.config.egressNetworkConnectors, + maximumDurationInSeconds: input.config.maximumDurationInSeconds, + logging: input.config.logging, + runHookPayload: input.runHookPayload, + clientToken: randomUUID(), + }; + + logger.debug('Launching Lambda MicroVM runner', { + imageIdentifier: commandInput.imageIdentifier, + imageVersion: commandInput.imageVersion, + maximumDurationInSeconds: commandInput.maximumDurationInSeconds, + }); + + const response = await microvmClient().send(new RunMicrovmCommand(commandInput)); + if (!response.microvmId) { + throw new Error('RunMicrovm returned no microvmId'); + } + + try { + await tagMicrovm(input.config.imageIdentifier, response.microvmId, { + [APPLICATION_TAG]: APPLICATION_TAG_VALUE, + 'ghr:created_by': input.source, + 'ghr:environment': input.environment, + 'ghr:Owner': input.runnerOwner, + 'ghr:Type': input.runnerType, + }); + } catch (error) { + logger.error(`Failed to tag new MicroVM runner '${response.microvmId}', terminating it`, { error }); + await terminateMicrovm(response.microvmId).catch((terminationError) => { + logger.error(`Failed to terminate untagged MicroVM runner '${response.microvmId}'`, { + error: terminationError, + }); + }); + throw error; + } + + return response.microvmId; +} + +export async function listMicrovmRunners(filters: ListRunnerFilters = {}): Promise { + const client = microvmClient(); + const items: MicrovmItem[] = []; + let nextToken: string | undefined; + + do { + const response = await client.send( + new ListMicrovmsCommand({ + maxResults: 50, + nextToken, + }), + ); + items.push(...(response.items ?? [])); + nextToken = response.nextToken; + } while (nextToken); + + const runners: MicrovmRunnerInfo[] = []; + for (const item of items) { + if (!item.microvmId || !item.imageArn || !item.state || !ACTIVE_STATES.has(item.state)) continue; + + let tags: Record; + try { + tags = + (await client.send(new ListTagsCommand({ Resource: microvmArn(item.imageArn, item.microvmId) }))).Tags ?? {}; + } catch (error) { + if (error instanceof Error && error.name === 'ResourceNotFoundException') continue; + throw error; + } + + if (tags[APPLICATION_TAG] !== APPLICATION_TAG_VALUE) continue; + if (filters.environment !== undefined && tags['ghr:environment'] !== filters.environment) continue; + if (filters.runnerType !== undefined && tags['ghr:Type'] !== filters.runnerType) continue; + if (filters.runnerOwner !== undefined && tags['ghr:Owner'] !== filters.runnerOwner) continue; + if (filters.orphan && tags['ghr:orphan'] !== 'true') continue; + + runners.push({ + id: item.microvmId, + imageArn: item.imageArn, + launchTime: item.startedAt, + owner: tags['ghr:Owner'], + type: tags['ghr:Type'] as RunnerInfo['type'], + orphan: tags['ghr:orphan'] === 'true', + githubRunnerId: tags['ghr:github_runner_id'], + bypassRemoval: tags['ghr:bypass-removal'] === 'true', + state: item.state, + }); + } + + return runners; +} + +export async function tagMicrovm(imageArn: string, microvmId: string, tags: Record): Promise { + await microvmClient().send( + new TagResourceCommand({ + Resource: microvmArn(imageArn, microvmId), + Tags: tags, + }), + ); +} + +export async function untagMicrovm(imageArn: string, microvmId: string, tagKeys: string[]): Promise { + await microvmClient().send( + new UntagResourceCommand({ + Resource: microvmArn(imageArn, microvmId), + TagKeys: tagKeys, + }), + ); +} + +export async function terminateMicrovm(microvmId: string): Promise { + await microvmClient().send(new TerminateMicrovmCommand({ microvmIdentifier: microvmId })); +} + +export function microvmBootTimeExceeded(runner: { launchTime?: Date }): boolean { + if (!runner.launchTime) return false; + + const bootTimeInMinutes = Number(process.env.RUNNER_BOOT_TIME_IN_MINUTES || '5'); + return runner.launchTime.getTime() + bootTimeInMinutes * 60_000 < Date.now(); +} + +export function isRetryableMicrovmError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const awsError = error as AwsErrorLike; + if (RETRYABLE_ERROR_NAMES.has(awsError.name)) return true; + + const statusCode = awsError.$metadata?.httpStatusCode; + if ( + awsError.$fault === 'server' || + statusCode === 429 || + (statusCode !== undefined && statusCode >= 500) || + (awsError.code !== undefined && RETRYABLE_NETWORK_ERROR_CODES.has(awsError.code)) + ) { + return true; + } + + return awsError.cause !== undefined && awsError.cause !== error ? isRetryableMicrovmError(awsError.cause) : false; +} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts new file mode 100644 index 0000000000..91c1931f83 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts @@ -0,0 +1,15 @@ +export {}; + +declare global { + namespace NodeJS { + interface ProcessEnv { + MICROVM_EGRESS_NETWORK_CONNECTORS: string | undefined; + MICROVM_EXECUTION_ROLE_ARN: string; + MICROVM_IMAGE_ARN: string; + MICROVM_IMAGE_VERSION: string | undefined; + MICROVM_INGRESS_NETWORK_CONNECTORS: string | undefined; + MICROVM_LOG_GROUP: string | undefined; + MICROVM_MAXIMUM_DURATION_IN_SECONDS: string | undefined; + } + } +} diff --git a/lambdas/libs/compute-providers/package.json b/lambdas/libs/compute-providers/package.json index a6fecab50c..888b28ebce 100644 --- a/lambdas/libs/compute-providers/package.json +++ b/lambdas/libs/compute-providers/package.json @@ -28,6 +28,7 @@ "@aws-github-runner/aws-ssm-util": "*", "@aws-github-runner/storage-providers": "*", "@aws-sdk/client-ec2": "^3.1009.0", + "@aws-sdk/client-lambda-microvms": "^3.1074.0", "@octokit/rest": "22.0.1", "moment": "2.29.4", "yn": "3.1.1" diff --git a/lambdas/libs/compute-providers/provider-types.ts b/lambdas/libs/compute-providers/provider-types.ts index 64d7be8e5f..087f61de71 100644 --- a/lambdas/libs/compute-providers/provider-types.ts +++ b/lambdas/libs/compute-providers/provider-types.ts @@ -1,4 +1,4 @@ -export const computeProviderTypes = ['ec2'] as const; +export const computeProviderTypes = ['ec2', 'microvm'] as const; export type ComputeProviderType = (typeof computeProviderTypes)[number]; diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index a703ceaa7a..0d17470f77 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -139,6 +139,7 @@ __metadata: "@aws-github-runner/aws-ssm-util": "npm:*" "@aws-github-runner/storage-providers": "npm:*" "@aws-sdk/client-ec2": "npm:^3.1009.0" + "@aws-sdk/client-lambda-microvms": "npm:^3.1074.0" "@octokit/rest": "npm:22.0.1" aws-sdk-client-mock: "npm:^4.1.0" aws-sdk-client-mock-jest: "npm:^4.1.0" @@ -443,6 +444,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-lambda-microvms@npm:^3.1074.0": + version: 3.1104.0 + resolution: "@aws-sdk/client-lambda-microvms@npm:3.1104.0" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/credential-provider-node": "npm:^3.972.78" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/fetch-http-handler": "npm:^5.6.13" + "@smithy/node-http-handler": "npm:^4.9.13" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/219ad52f822def4caa4a20d8d91d46a1b78e6726363a145be86c37cdeef4e4c13653e8a59ada67154146c6c2554e2c12944efad35c689850bf6f72f2d55246f4 + languageName: node + linkType: hard + "@aws-sdk/client-s3@npm:^3.1009.0": version: 3.1014.0 resolution: "@aws-sdk/client-s3@npm:3.1014.0" @@ -624,6 +641,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:^3.977.6": + version: 3.977.6 + resolution: "@aws-sdk/core@npm:3.977.6" + dependencies: + "@aws-sdk/types": "npm:^3.974.2" + "@aws-sdk/xml-builder": "npm:^3.972.37" + "@aws/lambda-invoke-store": "npm:^0.3.0" + "@smithy/core": "npm:^3.31.1" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.16.1" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10c0/4d743603bb41aeed426e2928be0947202191c341f9fbefe9ea347b0b4b7154b1ea94189d01c8abf3b03b9635449e2e7c268bd67379ea2294b9f49a61b909b9af + languageName: node + linkType: hard + "@aws-sdk/crc64-nvme@npm:^3.972.5": version: 3.972.5 resolution: "@aws-sdk/crc64-nvme@npm:3.972.5" @@ -647,6 +680,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:^3.972.67": + version: 3.972.67 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.67" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/547bcac01ac0912d0e42bb11f7d51bafcf2eaab1db35a098bea2be322211a86457ea60455a5294e58081c32376c240b07e66f81946a9be30e9722f723c6eaac2 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-http@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-http@npm:3.972.23" @@ -665,6 +711,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-http@npm:^3.972.69": + version: 3.972.69 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.69" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/fetch-http-handler": "npm:^5.6.13" + "@smithy/node-http-handler": "npm:^4.9.13" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/6e4cf9628919163a2a9784bf8618bc85a8c0ba7056813bedb9758c04eb3b36663f5099cfad329f89ac86c4e408bb3d0698ee7cff7e4a61c8a0335ab98078d678 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-ini@npm:3.972.23" @@ -687,6 +748,27 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:^3.973.12": + version: 3.973.12 + resolution: "@aws-sdk/credential-provider-ini@npm:3.973.12" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/credential-provider-env": "npm:^3.972.67" + "@aws-sdk/credential-provider-http": "npm:^3.972.69" + "@aws-sdk/credential-provider-login": "npm:^3.972.74" + "@aws-sdk/credential-provider-process": "npm:^3.972.67" + "@aws-sdk/credential-provider-sso": "npm:^3.973.11" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.73" + "@aws-sdk/nested-clients": "npm:^3.997.41" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/84646fee1c61e31b2052d902559ecf163c1d00558ecdc21d77b396250527348b9ebba324d0bf8ffee4b3e45476c691de502e6faad3d39d1f7420eee5d326c7c5 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-login@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-login@npm:3.972.23" @@ -703,6 +785,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-login@npm:^3.972.74": + version: 3.972.74 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.74" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/nested-clients": "npm:^3.997.41" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/1ab9996accb61bccbdaefae023e9befab9e5062435370a37f672089457dc13c485d9d2fee6926381672bdb32143c00266b1aa5913b18ef4873584907835b3a92 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:^3.972.24": version: 3.972.24 resolution: "@aws-sdk/credential-provider-node@npm:3.972.24" @@ -723,6 +819,25 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:^3.972.78": + version: 3.972.78 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.78" + dependencies: + "@aws-sdk/credential-provider-env": "npm:^3.972.67" + "@aws-sdk/credential-provider-http": "npm:^3.972.69" + "@aws-sdk/credential-provider-ini": "npm:^3.973.12" + "@aws-sdk/credential-provider-process": "npm:^3.972.67" + "@aws-sdk/credential-provider-sso": "npm:^3.973.11" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.73" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/2b6e5bd455a3c2b530a884a0c5919bb7d2d91941b655a56351b957de038d318c1d42b86674e20893ee7dab6db6ea32c4653bce9b1d3ca98ac803d6b49a948343 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:^3.972.21": version: 3.972.21 resolution: "@aws-sdk/credential-provider-process@npm:3.972.21" @@ -737,6 +852,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:^3.972.67": + version: 3.972.67 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.67" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/0381c39f171df2119791b03647545ab5084f6a8d2c227c5d3c5bfa9db027d0566102b6322bc09075c0779b0f0aa88ae1ca7bbdc773d8415d8dd8f163e69e45ea + languageName: node + linkType: hard + "@aws-sdk/credential-provider-sso@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-sso@npm:3.972.23" @@ -753,6 +881,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:^3.973.11": + version: 3.973.11 + resolution: "@aws-sdk/credential-provider-sso@npm:3.973.11" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/nested-clients": "npm:^3.997.41" + "@aws-sdk/token-providers": "npm:3.1103.0" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/d6df0ae72009c2f74f1c7f12e41c0a7b395ba1860d4f9f1554fd8fbc3b5f0c1be64c83aacd2b329561e27844520ae0b57bb268265f5e7850b1d0fb769455d7a1 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.23" @@ -768,6 +911,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:^3.972.73": + version: 3.972.73 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.73" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/nested-clients": "npm:^3.997.41" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/a7bee06b4200ff04141d4ce07d49d69f24b55b57f3aab929b445a9d9ea70f043d0b09fca8b95872d2b92df6837b771aeb14b03ca784d17ce1ee871b14173558c + languageName: node + linkType: hard + "@aws-sdk/lib-storage@npm:^3.1009.0": version: 3.1014.0 resolution: "@aws-sdk/lib-storage@npm:3.1014.0" @@ -1006,6 +1163,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/nested-clients@npm:^3.997.41": + version: 3.997.41 + resolution: "@aws-sdk/nested-clients@npm:3.997.41" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.43" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/fetch-http-handler": "npm:^5.6.13" + "@smithy/node-http-handler": "npm:^4.9.13" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/fe1a84bb58675a24ecd0ce3b7bcaf1a456494f10c1a9dd5b55bd268be6713f83bd3c3d3dadee6bb51a53bc24ee18b2b0882d6741bcbabc224feeae97454fdb4a + languageName: node + linkType: hard + "@aws-sdk/region-config-resolver@npm:^3.972.9": version: 3.972.9 resolution: "@aws-sdk/region-config-resolver@npm:3.972.9" @@ -1033,6 +1206,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/signature-v4-multi-region@npm:^3.996.43": + version: 3.996.43 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.43" + dependencies: + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/268608dd5624c6377243903d588b9c13b8de3f3f3e6bea68fc684d125bc92a991fd15a67cb178d1a7a599d0415ce5283f44ba6b96d14909b185d7ff26a9d979b + languageName: node + linkType: hard + "@aws-sdk/token-providers@npm:3.1014.0": version: 3.1014.0 resolution: "@aws-sdk/token-providers@npm:3.1014.0" @@ -1048,6 +1233,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.1103.0": + version: 3.1103.0 + resolution: "@aws-sdk/token-providers@npm:3.1103.0" + dependencies: + "@aws-sdk/core": "npm:^3.977.6" + "@aws-sdk/nested-clients": "npm:^3.997.41" + "@aws-sdk/types": "npm:^3.974.2" + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/5f86aa221e537b8a3fd11ed76ac025935f8859cc62b3af293abd759b8ca3aa390c17f6716723c08056b3373997a17bbdee2ff568eab5049bf0a372d35893b48d + languageName: node + linkType: hard + "@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.4.1, @aws-sdk/types@npm:^3.973.6": version: 3.973.6 resolution: "@aws-sdk/types@npm:3.973.6" @@ -1058,6 +1257,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:^3.974.2": + version: 3.974.2 + resolution: "@aws-sdk/types@npm:3.974.2" + dependencies: + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/b5ce05e8a4160c545edce1e8527e8ac490be7a6651c736f6811190b5d31d5682699889d51186ab0600df756679bebd2df9d650a17f577523441df803c4fb5777 + languageName: node + linkType: hard + "@aws-sdk/util-arn-parser@npm:^3.972.3": version: 3.972.3 resolution: "@aws-sdk/util-arn-parser@npm:3.972.3" @@ -1143,6 +1352,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/xml-builder@npm:^3.972.37": + version: 3.972.37 + resolution: "@aws-sdk/xml-builder@npm:3.972.37" + dependencies: + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/738f9302f495b3b95602641166a4182244add6e9e079201dba7e8994657dd442df0e4cea3355aa8c7d7f08efb385decaaf0b543f03efdb291c118536f36ac1a1 + languageName: node + linkType: hard + "@aws/lambda-invoke-store@npm:0.2.3, @aws/lambda-invoke-store@npm:^0.2.2": version: 0.2.3 resolution: "@aws/lambda-invoke-store@npm:0.2.3" @@ -1150,7 +1369,14 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": +"@aws/lambda-invoke-store@npm:^0.3.0": + version: 0.3.0 + resolution: "@aws/lambda-invoke-store@npm:0.3.0" + checksum: 10c0/b4a2e6b3b5397bc606053e64270d26dc5c886336f88a98cad587b1592eec17058f8fb172f1827a9f0e591f3595cf8f01575c8c9b36cde38c06456f8a65204046 + languageName: node + linkType: hard + +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" dependencies: @@ -4566,6 +4792,16 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.31.1": + version: 3.31.1 + resolution: "@smithy/core@npm:3.31.1" + dependencies: + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/b953c792dea2c13249b58c1799e4d6aaf21eb1a61e203b83e8e3a9156bebe14ca0585f0ca1ffdf65a193294dddff92a06fbe5c3fbd63ff0c174c88130b47a128 + languageName: node + linkType: hard + "@smithy/credential-provider-imds@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/credential-provider-imds@npm:4.2.12" @@ -4579,6 +4815,17 @@ __metadata: languageName: node linkType: hard +"@smithy/credential-provider-imds@npm:^4.4.16": + version: 4.4.16 + resolution: "@smithy/credential-provider-imds@npm:4.4.16" + dependencies: + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/d03687efbbd1f95e77b7dcb639f24f1600671929627cd743f7acf9640238746664e91f955026f22e235603e10537d46e31fa60f231adbdf37457e53720bc80f9 + languageName: node + linkType: hard + "@smithy/eventstream-codec@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/eventstream-codec@npm:4.2.12" @@ -4647,6 +4894,17 @@ __metadata: languageName: node linkType: hard +"@smithy/fetch-http-handler@npm:^5.6.13": + version: 5.6.13 + resolution: "@smithy/fetch-http-handler@npm:5.6.13" + dependencies: + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/028ba8794a6c487ebefae7f40d0124f70e51a1f4e0e465457845c1a44fd607320cd3c64d4a961f159aef59470f0fd43f0d2011b44ee5ef753b7e1dccbdf32ca3 + languageName: node + linkType: hard + "@smithy/hash-blob-browser@npm:^4.2.13": version: 4.2.13 resolution: "@smithy/hash-blob-browser@npm:4.2.13" @@ -4812,6 +5070,17 @@ __metadata: languageName: node linkType: hard +"@smithy/node-http-handler@npm:^4.9.13": + version: 4.9.13 + resolution: "@smithy/node-http-handler@npm:4.9.13" + dependencies: + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/2f1cdef7a300ad49c3bb698c2ca4773af5e9202d291cfcd855c1b21ab08b3c4ddf56f3722d3251db4e9b7ac39ec1ebc551b156abf3fa70f74c5491bec421f6b5 + languageName: node + linkType: hard + "@smithy/property-provider@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/property-provider@npm:4.2.12" @@ -4897,6 +5166,17 @@ __metadata: languageName: node linkType: hard +"@smithy/signature-v4@npm:^5.6.12": + version: 5.6.12 + resolution: "@smithy/signature-v4@npm:5.6.12" + dependencies: + "@smithy/core": "npm:^3.31.1" + "@smithy/types": "npm:^4.16.1" + tslib: "npm:^2.6.2" + checksum: 10c0/33656a41ad61dee16209703cb96b46b29014b3c4fad23bfbb90cdb5415ac06c6577b2bfff958ef9e6c19091364945135a0370b12ddc2daed557c903846e81fe7 + languageName: node + linkType: hard + "@smithy/smithy-client@npm:^4.12.7": version: 4.12.7 resolution: "@smithy/smithy-client@npm:4.12.7" @@ -4930,6 +5210,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^4.16.1": + version: 4.16.1 + resolution: "@smithy/types@npm:4.16.1" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/e024d9d148deca7bd21d032a9316db109bbe7cf256ffbb8d3981655b9f4f7695c08ec9b87f5a8cf1442e783ba26cb27e4f09603c5bfa3ba1e526c41b1b3e94d2 + languageName: node + linkType: hard + "@smithy/url-parser@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/url-parser@npm:4.2.12" From d8027728cc47c68bb4785a005760a29fe3942d49 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 6 Aug 2026 20:41:42 +0200 Subject: [PATCH 25/54] feat(compute-providers): add MicroVM control-plane provider --- .../aws/microvm/control-plane.ts | 25 +++ .../microvm/src/control-plane/pool.test.ts | 112 ++++++++++ .../aws/microvm/src/control-plane/pool.ts | 65 ++++++ .../src/control-plane/runner-config.test.ts | 191 ++++++++++++++++++ .../src/control-plane/runner-config.ts | 109 ++++++++++ .../src/control-plane/scale-down.test.ts | 74 +++++++ .../microvm/src/control-plane/scale-down.ts | 28 +++ .../src/control-plane/scale-up.test.ts | 114 +++++++++++ .../aws/microvm/src/control-plane/scale-up.ts | 77 +++++++ .../aws/microvm/src/dynamic-labels.test.ts | 73 +++++++ .../aws/microvm/src/dynamic-labels.ts | 90 +++++++++ lambdas/libs/compute-providers/package.json | 5 +- .../providers.config.control-plane.ts | 3 +- 13 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 lambdas/libs/compute-providers/aws/microvm/control-plane.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.ts diff --git a/lambdas/libs/compute-providers/aws/microvm/control-plane.ts b/lambdas/libs/compute-providers/aws/microvm/control-plane.ts new file mode 100644 index 0000000000..d6287ca1e1 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/control-plane.ts @@ -0,0 +1,25 @@ +import type { ComputeProviderPlugin, CreateStartRunnerConfig } from '../../core'; + +import type { ControlPlaneProviderCapabilities, ControlPlaneProviderModule } from '../../contracts'; +import type {} from './src/environment'; +import { createMicrovmPoolProvider } from './src/control-plane/pool'; +import { createMicrovmScaleDownProvider } from './src/control-plane/scale-down'; +import { createMicrovmScaleUpProvider } from './src/control-plane/scale-up'; + +export function createMicrovmControlPlanePlugin( + createStartRunnerConfig: CreateStartRunnerConfig, +): ComputeProviderPlugin { + return { + type: 'microvm', + capabilities: { + pool: () => createMicrovmPoolProvider(createStartRunnerConfig), + scaleUp: () => createMicrovmScaleUpProvider(createStartRunnerConfig), + scaleDown: createMicrovmScaleDownProvider, + }, + }; +} + +export const provider = { + type: 'microvm', + createPlugin: createMicrovmControlPlanePlugin, +} satisfies ControlPlaneProviderModule<'microvm'>; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.test.ts new file mode 100644 index 0000000000..8f46818b50 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.test.ts @@ -0,0 +1,112 @@ +import type { Octokit } from '@octokit/rest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CreateGitHubRunnerConfig, CreateStartRunnerConfig } from '../../../../core'; +import { listMicrovmRunners, microvmBootTimeExceeded } from './microvms'; +import type { MicrovmRunnerInfo } from './microvms'; +import { calculateMicrovmPoolSize, createMicrovmPoolProvider } from './pool'; +import { createMicrovmRunners } from './runner-config'; + +vi.mock('./microvms', () => ({ + listMicrovmRunners: vi.fn(), + microvmBootTimeExceeded: vi.fn(), +})); +vi.mock('./runner-config', () => ({ createMicrovmRunners: vi.fn() })); + +const createStartRunnerConfig = vi.fn(); +const githubClient = {} as Octokit; +function runner(id: string, state: MicrovmRunnerInfo['state']): MicrovmRunnerInfo { + return { id, state, owner: 'Codertocat', type: 'Org' }; +} + +function githubRunnerConfig(): CreateGitHubRunnerConfig { + return { + ephemeral: true, + enableJitConfig: true, + runnerLabels: 'self-hosted,microvm', + runnerGroup: 'Default', + runnerNamePrefix: '', + runnerOwner: 'Codertocat', + runnerType: 'Org', + disableAutoUpdate: true, + ssmTokenPath: '/runner/token', + ssmConfigPath: '/runner/config', + ssmParameterStoreTags: [], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listMicrovmRunners).mockResolvedValue([]); + vi.mocked(microvmBootTimeExceeded).mockReturnValue(false); + vi.mocked(createMicrovmRunners).mockResolvedValue({ + instances: ['mvm-1'], + retryableErrorCount: 0, + nonRetryableErrorCount: 0, + }); +}); + +describe('calculateMicrovmPoolSize', () => { + it('counts online idle running runners', () => { + expect( + calculateMicrovmPoolSize( + [runner('mvm-idle', 'RUNNING')], + new Map([['mvm-idle', { busy: false, status: 'online' }]]), + ), + ).toBe(1); + }); + + it('optionally counts online busy runners', () => { + const runners = [runner('mvm-busy', 'RUNNING')]; + const statuses = new Map([['mvm-busy', { busy: true, status: 'online' }]]); + + expect(calculateMicrovmPoolSize(runners, statuses)).toBe(0); + expect(calculateMicrovmPoolSize(runners, statuses, true)).toBe(1); + }); + + it('counts pending runners only during their boot window', () => { + const runners = [runner('mvm-pending', 'PENDING')]; + vi.mocked(microvmBootTimeExceeded).mockReturnValueOnce(false).mockReturnValueOnce(true); + + expect(calculateMicrovmPoolSize(runners, new Map())).toBe(1); + expect(calculateMicrovmPoolSize(runners, new Map())).toBe(0); + }); + + it('does not count suspended or offline runners', () => { + expect( + calculateMicrovmPoolSize( + [runner('mvm-suspended', 'SUSPENDED'), runner('mvm-offline', 'RUNNING')], + new Map([['mvm-offline', { busy: false, status: 'offline' }]]), + ), + ).toBe(0); + }); +}); + +describe('createMicrovmPoolProvider', () => { + it('lists managed MicroVMs and returns successfully created IDs', async () => { + const provider = createMicrovmPoolProvider(createStartRunnerConfig); + const input = { + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org' as const, + }; + + await expect(provider.listRunners(input)).resolves.toEqual([]); + expect(listMicrovmRunners).toHaveBeenCalledWith(input); + + await expect( + provider.createRunners({ + githubRunnerConfig: githubRunnerConfig(), + numberOfRunners: 1, + githubInstallationClient: githubClient, + }), + ).resolves.toEqual(['mvm-1']); + expect(createMicrovmRunners).toHaveBeenCalledWith( + expect.any(Object), + 1, + githubClient, + createStartRunnerConfig, + 'pool-lambda', + ); + }); +}); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.ts new file mode 100644 index 0000000000..8deed5562d --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.ts @@ -0,0 +1,65 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; + +import type { + CreatePoolRunnersInput, + CreateStartRunnerConfig, + ListPoolRunnersInput, + PoolComputeProvider, + RunnerStatus, +} from '../../../../core'; +import type { MicrovmRunnerInfo } from './microvms'; +import { listMicrovmRunners, microvmBootTimeExceeded } from './microvms'; +import { createMicrovmRunners } from './runner-config'; + +const logger = createChildLogger('microvm-pool'); + +async function listMicrovmPoolRunners(input: ListPoolRunnersInput): Promise { + return await listMicrovmRunners(input); +} + +async function createMicrovmPoolRunners( + { githubRunnerConfig, numberOfRunners, githubInstallationClient }: CreatePoolRunnersInput, + createStartRunnerConfig: CreateStartRunnerConfig, +): Promise { + const result = await createMicrovmRunners( + githubRunnerConfig, + numberOfRunners, + githubInstallationClient, + createStartRunnerConfig, + 'pool-lambda', + ); + return result.instances; +} + +export function calculateMicrovmPoolSize( + runners: MicrovmRunnerInfo[], + runnerStatus: Map, + includeBusyRunners = false, +): number { + let availableRunners = 0; + + for (const runner of runners) { + const status = runnerStatus.get(runner.id); + if (runner.state === 'RUNNING' && status?.status === 'online' && (!status.busy || includeBusyRunners)) { + availableRunners++; + logger.debug(`MicroVM runner ${runner.id} is online and counted as part of the pool`); + } else if (runner.state === 'PENDING' && !microvmBootTimeExceeded(runner)) { + availableRunners++; + logger.info(`MicroVM runner ${runner.id} is still booting and counted as part of the pool`); + } else { + logger.debug(`MicroVM runner ${runner.id} is not available and is not counted as part of the pool`); + } + } + + return availableRunners; +} + +export function createMicrovmPoolProvider( + createStartRunnerConfig: CreateStartRunnerConfig, +): Omit, 'type'> { + return { + listRunners: listMicrovmPoolRunners, + countAvailableRunners: calculateMicrovmPoolSize, + createRunners: (input) => createMicrovmPoolRunners(input, createStartRunnerConfig), + }; +} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts new file mode 100644 index 0000000000..84afb5be3b --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts @@ -0,0 +1,191 @@ +import type { Octokit } from '@octokit/rest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CreateGitHubRunnerConfig, CreateStartRunnerConfig } from '../../../../core'; +import { loadMicrovmProviderConfig } from './config'; +import { isRetryableMicrovmError, runMicrovmRunner, tagMicrovm, terminateMicrovm } from './microvms'; +import { createMicrovmRunHookPayload, createMicrovmRunners } from './runner-config'; + +vi.mock('./config', () => ({ loadMicrovmProviderConfig: vi.fn() })); +vi.mock('./microvms', () => ({ + isRetryableMicrovmError: vi.fn(), + runMicrovmRunner: vi.fn(), + tagMicrovm: vi.fn(), + terminateMicrovm: vi.fn(), +})); + +const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; +const githubClient = {} as Octokit; +const createStartRunnerConfig = vi.fn(); + +function runnerConfig(overrides: Partial = {}): CreateGitHubRunnerConfig { + return { + ephemeral: true, + enableJitConfig: true, + runnerLabels: 'self-hosted,linux,arm64,microvm', + runnerGroup: 'Default', + runnerNamePrefix: 'unit-test-', + runnerOwner: 'Codertocat', + runnerType: 'Org', + disableAutoUpdate: true, + ssmTokenPath: '/github-action-runners/unit-test/token', + ssmConfigPath: '/github-action-runners/unit-test/config', + ssmParameterStoreTags: [], + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.ENVIRONMENT = 'unit-test'; + vi.mocked(loadMicrovmProviderConfig).mockReturnValue({ + imageIdentifier: imageArn, + executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', + maximumDurationInSeconds: 1200, + }); + vi.mocked(runMicrovmRunner).mockResolvedValue('mvm-1'); + vi.mocked(tagMicrovm).mockResolvedValue(); + vi.mocked(terminateMicrovm).mockResolvedValue(); + vi.mocked(isRetryableMicrovmError).mockReturnValue(false); + createStartRunnerConfig.mockResolvedValue([]); +}); + +describe('createMicrovmRunHookPayload', () => { + it('contains only the versioned SSM prefix contract', () => { + expect(JSON.parse(createMicrovmRunHookPayload('/runner/token'))).toEqual({ + version: 1, + runnerConfigSsmPath: '/runner/token', + }); + }); +}); + +describe('createMicrovmRunners', () => { + it.each([{ ephemeral: false }, { enableJitConfig: false }])( + 'rejects unsupported runner configuration %j', + async (overrides) => { + await expect( + createMicrovmRunners(runnerConfig(overrides), 2, githubClient, createStartRunnerConfig, 'scale-up-lambda'), + ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 2 }); + + expect(runMicrovmRunner).not.toHaveBeenCalled(); + }, + ); + + it('requires an SSM token path', async () => { + await expect( + createMicrovmRunners( + runnerConfig({ ssmTokenPath: '' }), + 1, + githubClient, + createStartRunnerConfig, + 'scale-up-lambda', + ), + ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); + }); + + it('classifies invalid provider configuration as non-retryable', async () => { + vi.mocked(loadMicrovmProviderConfig).mockImplementation(() => { + throw new Error('missing image'); + }); + + await expect( + createMicrovmRunners(runnerConfig(), 3, githubClient, createStartRunnerConfig, 'scale-up-lambda'), + ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 3 }); + }); + + it('launches each MicroVM and delivers its JIT configuration', async () => { + vi.mocked(runMicrovmRunner).mockResolvedValueOnce('mvm-1').mockResolvedValueOnce('mvm-2'); + createStartRunnerConfig.mockImplementation(async (_config, runnerIds, _client, options) => { + await options?.onJitConfigCreated?.(runnerIds[0], { githubRunnerId: `github-${runnerIds[0]}`, runnerLabels: [] }); + return []; + }); + + await expect( + createMicrovmRunners(runnerConfig(), 2, githubClient, createStartRunnerConfig, 'pool-lambda'), + ).resolves.toEqual({ instances: ['mvm-1', 'mvm-2'], retryableErrorCount: 0, nonRetryableErrorCount: 0 }); + + expect(runMicrovmRunner).toHaveBeenNthCalledWith(1, { + config: expect.objectContaining({ imageIdentifier: imageArn }), + environment: 'unit-test', + runHookPayload: createMicrovmRunHookPayload('/github-action-runners/unit-test/token'), + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'pool-lambda', + }); + expect(createStartRunnerConfig).toHaveBeenCalledTimes(2); + const options = createStartRunnerConfig.mock.calls[0][3]; + expect(options?.getSsmParameterTags?.('mvm-1')).toEqual([{ Key: 'MicrovmId', Value: 'mvm-1' }]); + expect(tagMicrovm).toHaveBeenNthCalledWith(1, imageArn, 'mvm-1', { + 'ghr:github_runner_id': 'github-mvm-1', + }); + }); + + it('applies dynamic labels to the RunMicrovm configuration and metadata tags', async () => { + const overrideImageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-large'; + const overrideEgressConnectorArn = + 'arn:aws:lambda:eu-west-1:123456789012:network-connector:github-runner-private-egress'; + createStartRunnerConfig.mockImplementation(async (_config, runnerIds, _client, options) => { + await options?.onJitConfigCreated?.(runnerIds[0], { githubRunnerId: 'github-mvm-1', runnerLabels: [] }); + return []; + }); + + await createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda', { + egressNetworkConnectors: [overrideEgressConnectorArn], + imageIdentifier: overrideImageArn, + imageVersion: '3.0', + maximumDurationInSeconds: 7200, + }); + + expect(runMicrovmRunner).toHaveBeenCalledWith({ + config: { + egressNetworkConnectors: [overrideEgressConnectorArn], + imageIdentifier: overrideImageArn, + imageVersion: '3.0', + executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', + maximumDurationInSeconds: 7200, + }, + environment: 'unit-test', + runHookPayload: createMicrovmRunHookPayload('/github-action-runners/unit-test/token'), + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'scale-up-lambda', + }); + expect(tagMicrovm).toHaveBeenCalledWith(overrideImageArn, 'mvm-1', { + 'ghr:github_runner_id': 'github-mvm-1', + }); + }); + + it('retries a JIT setup failure even when runner cleanup fails', async () => { + createStartRunnerConfig.mockResolvedValue(['mvm-1']); + vi.mocked(terminateMicrovm).mockRejectedValue(new Error('cleanup failed')); + + await expect( + createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), + ).resolves.toEqual({ instances: [], retryableErrorCount: 1, nonRetryableErrorCount: 0 }); + + expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1'); + }); + + it.each([ + [true, { instances: [], retryableErrorCount: 1, nonRetryableErrorCount: 0 }], + [false, { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }], + ])('classifies launch failures with retryable=%s', async (retryable, expected) => { + vi.mocked(runMicrovmRunner).mockRejectedValue(new Error('launch failed')); + vi.mocked(isRetryableMicrovmError).mockReturnValue(retryable); + + await expect( + createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), + ).resolves.toEqual(expected); + }); + + it('attempts cleanup when setup throws after launch', async () => { + createStartRunnerConfig.mockRejectedValue(new Error('JIT setup failed')); + vi.mocked(terminateMicrovm).mockRejectedValue(new Error('cleanup failed')); + + await expect( + createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), + ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); + + expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1'); + }); +}); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts new file mode 100644 index 0000000000..ca3497dd0e --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -0,0 +1,109 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import type { Octokit } from '@octokit/rest'; + +import type { + CreateGitHubRunnerConfig, + CreateRunnerResult, + CreateStartRunnerConfig, + LambdaRunnerSource, +} from '../../../../core'; +import type { MicrovmDynamicLabelOverrides } from '../dynamic-labels'; +import { loadMicrovmProviderConfig } from './config'; +import { isRetryableMicrovmError, runMicrovmRunner, tagMicrovm, terminateMicrovm } from './microvms'; + +const logger = createChildLogger('microvm-runner-config'); + +export interface MicrovmRunHookPayloadV1 { + runnerConfigSsmPath: string; + version: 1; +} + +export function createMicrovmRunHookPayload(ssmTokenPath: string): string { + return JSON.stringify({ + version: 1, + runnerConfigSsmPath: ssmTokenPath, + } satisfies MicrovmRunHookPayloadV1); +} + +export async function createMicrovmRunners( + githubRunnerConfig: CreateGitHubRunnerConfig, + numberOfRunners: number, + githubInstallationClient: Octokit, + createStartRunnerConfig: CreateStartRunnerConfig, + source: LambdaRunnerSource, + overrides: MicrovmDynamicLabelOverrides = {}, +): Promise { + if (!githubRunnerConfig.ephemeral || !githubRunnerConfig.enableJitConfig) { + logger.error('Lambda MicroVM runners require ephemeral runners with JIT configuration enabled'); + return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; + } + + if (!githubRunnerConfig.ssmTokenPath?.trim()) { + logger.error('Lambda MicroVM runners require SSM_TOKEN_PATH to deliver JIT configuration'); + return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; + } + + let config; + try { + config = { ...loadMicrovmProviderConfig(), ...overrides }; + } catch (error) { + logger.error('Invalid Lambda MicroVM provider configuration', { error }); + return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; + } + + const result: CreateRunnerResult = { + instances: [], + retryableErrorCount: 0, + nonRetryableErrorCount: 0, + }; + const runHookPayload = createMicrovmRunHookPayload(githubRunnerConfig.ssmTokenPath); + + for (let runnerIndex = 0; runnerIndex < numberOfRunners; runnerIndex++) { + let microvmId: string | undefined; + try { + microvmId = await runMicrovmRunner({ + config, + environment: process.env.ENVIRONMENT, + runHookPayload, + runnerOwner: githubRunnerConfig.runnerOwner, + runnerType: githubRunnerConfig.runnerType, + source, + }); + + const failedRunnerIds = await createStartRunnerConfig(githubRunnerConfig, [microvmId], githubInstallationClient, { + getSsmParameterTags: (runnerId) => [{ Key: 'MicrovmId', Value: runnerId }], + onJitConfigCreated: async (runnerId, metadata) => { + await tagMicrovm(config.imageIdentifier, runnerId, { + 'ghr:github_runner_id': metadata.githubRunnerId, + }); + }, + }); + + if (failedRunnerIds.includes(microvmId)) { + await terminateMicrovm(microvmId).catch((terminationError) => { + logger.error(`Failed to terminate MicroVM runner '${microvmId}' after JIT configuration failed`, { + error: terminationError, + }); + }); + result.retryableErrorCount++; + } else { + result.instances.push(microvmId); + } + } catch (error) { + if (microvmId) { + await terminateMicrovm(microvmId).catch((terminationError) => { + logger.error(`Failed to terminate MicroVM runner '${microvmId}' after setup failed`, { + error: terminationError, + }); + }); + } + + const retryable = isRetryableMicrovmError(error); + logger.error('Failed to create Lambda MicroVM runner', { error, retryable }); + if (retryable) result.retryableErrorCount++; + else result.nonRetryableErrorCount++; + } + } + + return result; +} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts new file mode 100644 index 0000000000..613364e7d2 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { loadMicrovmProviderConfig } from './config'; +import { listMicrovmRunners, microvmBootTimeExceeded, tagMicrovm, terminateMicrovm, untagMicrovm } from './microvms'; +import { createMicrovmScaleDownProvider } from './scale-down'; + +vi.mock('./config', () => ({ loadMicrovmProviderConfig: vi.fn() })); +vi.mock('./microvms', () => ({ + listMicrovmRunners: vi.fn(), + microvmBootTimeExceeded: vi.fn(), + tagMicrovm: vi.fn(), + terminateMicrovm: vi.fn(), + untagMicrovm: vi.fn(), +})); + +const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; +const overrideImageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-large'; +const providerConfig = { + imageIdentifier: imageArn, + executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', + maximumDurationInSeconds: 1200, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(loadMicrovmProviderConfig).mockReturnValue(providerConfig); + vi.mocked(listMicrovmRunners).mockResolvedValue([]); + vi.mocked(microvmBootTimeExceeded).mockReturnValue(false); + vi.mocked(tagMicrovm).mockResolvedValue(); + vi.mocked(untagMicrovm).mockResolvedValue(); + vi.mocked(terminateMicrovm).mockResolvedValue(); +}); + +describe('createMicrovmScaleDownProvider', () => { + it('lists active and orphan runners through provider filters', async () => { + const provider = createMicrovmScaleDownProvider(); + + await provider.list('unit-test'); + await provider.list('unit-test', true); + + expect(listMicrovmRunners).toHaveBeenNthCalledWith(1, { + environment: 'unit-test', + orphan: undefined, + }); + expect(listMicrovmRunners).toHaveBeenNthCalledWith(2, { + environment: 'unit-test', + orphan: true, + }); + }); + + it('uses the listed image ARN when marking, unmarking, and terminating runners', async () => { + vi.mocked(listMicrovmRunners).mockResolvedValue([ + { id: 'mvm-1', imageArn: overrideImageArn, owner: 'Codertocat', type: 'Org', state: 'RUNNING' }, + ]); + const provider = createMicrovmScaleDownProvider(); + + await provider.list('unit-test'); + await provider.markOrphan('mvm-1'); + await provider.unmarkOrphan('mvm-1'); + await provider.terminate('mvm-1'); + + expect(tagMicrovm).toHaveBeenCalledWith(overrideImageArn, 'mvm-1', { 'ghr:orphan': 'true' }); + expect(untagMicrovm).toHaveBeenCalledWith(overrideImageArn, 'mvm-1', ['ghr:orphan']); + expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1'); + }); + + it('uses the MicroVM boot-time policy', () => { + const provider = createMicrovmScaleDownProvider(); + const runner = { id: 'mvm-1', owner: 'Codertocat', type: 'Org' as const }; + + expect(provider.bootTimeExceeded(runner)).toBe(false); + expect(microvmBootTimeExceeded).toHaveBeenCalledWith(runner); + }); +}); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts new file mode 100644 index 0000000000..9ea9dc474a --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts @@ -0,0 +1,28 @@ +import type { ScaleDownComputeProvider } from '../../../../core'; +import { loadMicrovmProviderConfig } from './config'; +import type { MicrovmRunnerInfo } from './microvms'; +import { listMicrovmRunners, microvmBootTimeExceeded, tagMicrovm, terminateMicrovm, untagMicrovm } from './microvms'; + +export function createMicrovmScaleDownProvider(): Omit { + const imageArnByRunnerId = new Map(); + + async function list(environment: string, orphan?: boolean): Promise { + const runners = await listMicrovmRunners({ environment, orphan }); + for (const runner of runners) { + if (runner.imageArn) imageArnByRunnerId.set(runner.id, runner.imageArn); + } + return runners; + } + + function imageArnForRunner(id: string): string { + return imageArnByRunnerId.get(id) ?? loadMicrovmProviderConfig().imageIdentifier; + } + + return { + list, + bootTimeExceeded: microvmBootTimeExceeded, + markOrphan: async (id) => await tagMicrovm(imageArnForRunner(id), id, { 'ghr:orphan': 'true' }), + unmarkOrphan: async (id) => await untagMicrovm(imageArnForRunner(id), id, ['ghr:orphan']), + terminate: terminateMicrovm, + }; +} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts new file mode 100644 index 0000000000..cfbbda3257 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts @@ -0,0 +1,114 @@ +import type { Octokit } from '@octokit/rest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CreateGitHubRunnerConfig, CreateStartRunnerConfig } from '../../../../core'; +import { listMicrovmRunners } from './microvms'; +import { createMicrovmRunners } from './runner-config'; +import { createMicrovmScaleUpProvider } from './scale-up'; + +vi.mock('./microvms', () => ({ listMicrovmRunners: vi.fn() })); +vi.mock('./runner-config', () => ({ createMicrovmRunners: vi.fn() })); + +const createStartRunnerConfig = vi.fn(); +const githubClient = {} as Octokit; +const overrideImageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-large'; +const overrideEgressConnectorArn = + 'arn:aws:lambda:eu-west-1:123456789012:network-connector:github-runner-private-egress'; +const githubRunnerConfig: CreateGitHubRunnerConfig = { + ephemeral: true, + enableJitConfig: true, + runnerLabels: 'self-hosted,linux,arm64,microvm', + runnerGroup: 'Default', + runnerNamePrefix: '', + runnerOwner: 'Codertocat', + runnerType: 'Org', + disableAutoUpdate: true, + ssmTokenPath: '/runner/token', + ssmConfigPath: '/runner/config', + ssmParameterStoreTags: [], +}; + +beforeEach(() => { + vi.clearAllMocks(); + process.env.ENVIRONMENT = 'unit-test'; + vi.mocked(listMicrovmRunners).mockResolvedValue([ + { id: 'mvm-current', owner: 'Codertocat', type: 'Org', state: 'RUNNING' }, + ]); + vi.mocked(createMicrovmRunners).mockResolvedValue({ + instances: ['mvm-new'], + retryableErrorCount: 0, + nonRetryableErrorCount: 0, + }); +}); + +describe('createMicrovmScaleUpProvider', () => { + it('resolves supported resource override labels and registers them on the runner', async () => { + const provider = createMicrovmScaleUpProvider(createStartRunnerConfig); + + await expect( + provider.resolveLabelsForRunners([ + `ghr-microvm-egress-network-connectors:${overrideEgressConnectorArn}`, + `ghr-microvm-image-arn:${overrideImageArn}`, + 'ghr-microvm-image-version:3.0', + 'ghr-microvm-maximum-duration-in-seconds:7200', + ]), + ).resolves.toEqual({ + runnerLabels: [ + `ghr-microvm-egress-network-connectors:${overrideEgressConnectorArn}`, + `ghr-microvm-image-arn:${overrideImageArn}`, + 'ghr-microvm-image-version:3.0', + 'ghr-microvm-maximum-duration-in-seconds:7200', + ], + state: { + overrides: { + egressNetworkConnectors: [overrideEgressConnectorArn], + imageIdentifier: overrideImageArn, + imageVersion: '3.0', + maximumDurationInSeconds: 7200, + }, + }, + }); + }); + + it('rejects unsupported MicroVM override labels at the control-plane boundary', async () => { + const provider = createMicrovmScaleUpProvider(createStartRunnerConfig); + + await expect(provider.resolveLabelsForRunners(['ghr-microvm-memory:8192'])).rejects.toThrow( + "key 'memory' is not a supported MicroVM override", + ); + }); + + it('counts managed MicroVMs for the runner owner', async () => { + const provider = createMicrovmScaleUpProvider(createStartRunnerConfig); + + await expect( + provider.getCurrentRunners({ overrides: {} }, { runnerOwner: 'Codertocat', runnerType: 'Org' }), + ).resolves.toBe(1); + expect(listMicrovmRunners).toHaveBeenCalledWith({ + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org', + }); + }); + + it('delegates runner creation to the shared MicroVM lifecycle', async () => { + const provider = createMicrovmScaleUpProvider(createStartRunnerConfig); + + await expect( + provider.createRunners({ + githubRunnerConfig, + numberOfRunners: 1, + githubInstallationClient: githubClient, + state: { overrides: { imageVersion: '3.0' } }, + }), + ).resolves.toEqual({ instances: ['mvm-new'], retryableErrorCount: 0, nonRetryableErrorCount: 0 }); + expect(createMicrovmRunners).toHaveBeenCalledWith( + githubRunnerConfig, + 1, + githubClient, + createStartRunnerConfig, + 'scale-up-lambda', + { imageVersion: '3.0' }, + ); + }); +}); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.ts new file mode 100644 index 0000000000..a3dcf1219e --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.ts @@ -0,0 +1,77 @@ +import type { + CreateRunnerResult, + CreateScaleUpRunnersInput, + CreateStartRunnerConfig, + CurrentRunnersInput, + RunnerLabelResolution, + ScaleUpComputeProvider, +} from '../../../../core'; +import type { MicrovmDynamicLabelOverrides } from '../dynamic-labels'; +import { parseMicrovmDynamicLabels } from '../dynamic-labels'; +import { listMicrovmRunners } from './microvms'; +import { createMicrovmRunners } from './runner-config'; + +interface MicrovmScaleUpState { + overrides: MicrovmDynamicLabelOverrides; +} + +async function resolveMicrovmLabelsForRunners( + messageLabels: string[], +): Promise> { + const trimmedLabels = messageLabels.map((label) => label.trim()); + const parsed = parseMicrovmDynamicLabels(trimmedLabels); + if (parsed.violations.length > 0) { + throw new Error( + `Invalid MicroVM dynamic labels: ${parsed.violations + .map((violation) => `${violation.label} (${violation.reason})`) + .join(', ')}`, + ); + } + + return { + runnerLabels: trimmedLabels.filter((label) => label.startsWith('ghr-')), + state: { overrides: parsed.overrides }, + }; +} + +async function getCurrentMicrovmRunners( + _state: MicrovmScaleUpState, + { runnerType, runnerOwner }: CurrentRunnersInput, +): Promise { + return ( + await listMicrovmRunners({ + environment: process.env.ENVIRONMENT, + runnerType, + runnerOwner, + }) + ).length; +} + +async function createMicrovmScaleUpRunners( + { + githubRunnerConfig, + numberOfRunners, + githubInstallationClient, + state, + }: CreateScaleUpRunnersInput, + createStartRunnerConfig: CreateStartRunnerConfig, +): Promise { + return await createMicrovmRunners( + githubRunnerConfig, + numberOfRunners, + githubInstallationClient, + createStartRunnerConfig, + 'scale-up-lambda', + state.overrides, + ); +} + +export function createMicrovmScaleUpProvider( + createStartRunnerConfig: CreateStartRunnerConfig, +): Omit, 'type'> { + return { + resolveLabelsForRunners: resolveMicrovmLabelsForRunners, + getCurrentRunners: getCurrentMicrovmRunners, + createRunners: (input) => createMicrovmScaleUpRunners(input, createStartRunnerConfig), + }; +} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.test.ts new file mode 100644 index 0000000000..6ea669a143 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMicrovmDynamicLabels } from './dynamic-labels'; + +const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-large'; +const egressConnectorArn = 'arn:aws:lambda:eu-west-1:123456789012:network-connector:github-runner-private-egress'; +const internetEgressConnectorArn = + 'arn:aws:lambda:eu-west-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS'; + +describe('parseMicrovmDynamicLabels', () => { + it('parses every supported RunMicrovm override', () => { + expect( + parseMicrovmDynamicLabels([ + `ghr-microvm-egress-network-connectors:${egressConnectorArn}`, + `ghr-microvm-egress-network-connectors:${internetEgressConnectorArn}`, + `ghr-microvm-image-arn:${imageArn}`, + 'ghr-microvm-image-version:3.0', + 'ghr-microvm-maximum-duration-in-seconds:7200', + ]), + ).toEqual({ + overrides: { + egressNetworkConnectors: [egressConnectorArn, internetEgressConnectorArn], + imageIdentifier: imageArn, + imageVersion: '3.0', + maximumDurationInSeconds: 7200, + }, + violations: [], + }); + }); + + it.each([ + ['ghr-microvm-memory:8192', "key 'memory' is not a supported MicroVM override"], + [ + 'ghr-microvm-egress-network-connectors:not-an-arn', + 'is not a valid Lambda network connector ARN; specify one ARN per label', + ], + [ + `ghr-microvm-egress-network-connectors:${egressConnectorArn};${internetEgressConnectorArn}`, + 'is not a valid Lambda network connector ARN; specify one ARN per label', + ], + ['ghr-microvm-image-arn:not-an-arn', 'is not a valid customer MicroVM image ARN'], + ['ghr-microvm-image-version:', "key 'image-version' requires a value"], + ['ghr-microvm-maximum-duration-in-seconds:0', 'maximum duration must be an integer between 1 and 28800'], + ['ghr-microvm-maximum-duration-in-seconds:28801', 'maximum duration must be an integer between 1 and 28800'], + ])('rejects invalid override %s', (label, reason) => { + const result = parseMicrovmDynamicLabels([label]); + + expect(result.overrides).toEqual({}); + expect(result.violations).toEqual([{ label, reason: expect.stringContaining(reason) }]); + }); + + it('ignores generic dynamic labels', () => { + expect(parseMicrovmDynamicLabels(['ghr-team:platform'])).toEqual({ overrides: {}, violations: [] }); + }); + + it('rejects more than ten egress network connectors', () => { + const labels = Array.from( + { length: 11 }, + (_, index) => + `ghr-microvm-egress-network-connectors:arn:aws:lambda:eu-west-1:123456789012:network-connector:connector-${index}`, + ); + + const result = parseMicrovmDynamicLabels(labels); + + expect(result.overrides.egressNetworkConnectors).toHaveLength(10); + expect(result.violations).toEqual([ + { + label: labels[10], + reason: 'at most 10 egress network connector labels are supported', + }, + ]); + }); +}); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.ts b/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.ts new file mode 100644 index 0000000000..50c223cfd2 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.ts @@ -0,0 +1,90 @@ +export const MICROVM_DYNAMIC_LABEL_PREFIX = 'ghr-microvm-'; + +const MAXIMUM_DURATION_IN_SECONDS = 28_800; +const MAXIMUM_EGRESS_NETWORK_CONNECTORS = 10; +const MICROVM_IMAGE_ARN_PATTERN = /^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$/; +const MICROVM_NETWORK_CONNECTOR_ARN_PATTERN = + /^arn:aws[a-zA-Z-]*:lambda:[a-z0-9-]+:(?:[0-9]{12}|aws):network-connector:[a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)?$/; + +export interface MicrovmDynamicLabelOverrides { + egressNetworkConnectors?: string[]; + imageIdentifier?: string; + imageVersion?: string; + maximumDurationInSeconds?: number; +} + +export interface MicrovmDynamicLabelViolation { + label: string; + reason: string; +} + +export function parseMicrovmDynamicLabels(labels: string[]): { + overrides: MicrovmDynamicLabelOverrides; + violations: MicrovmDynamicLabelViolation[]; +} { + const overrides: MicrovmDynamicLabelOverrides = {}; + const violations: MicrovmDynamicLabelViolation[] = []; + + for (const label of labels) { + if (!label.startsWith(MICROVM_DYNAMIC_LABEL_PREFIX)) continue; + + const stripped = label.slice(MICROVM_DYNAMIC_LABEL_PREFIX.length); + const colonIndex = stripped.indexOf(':'); + const key = colonIndex === -1 ? stripped : stripped.slice(0, colonIndex); + const value = colonIndex === -1 ? '' : stripped.slice(colonIndex + 1).trim(); + + if (!value) { + violations.push({ label, reason: `key '${key}' requires a value` }); + continue; + } + + switch (key) { + case 'egress-network-connectors': { + if (!MICROVM_NETWORK_CONNECTOR_ARN_PATTERN.test(value)) { + violations.push({ + label, + reason: `'${value}' is not a valid Lambda network connector ARN; specify one ARN per label`, + }); + break; + } + + const connectors = overrides.egressNetworkConnectors ?? []; + if (connectors.length >= MAXIMUM_EGRESS_NETWORK_CONNECTORS) { + violations.push({ + label, + reason: `at most ${MAXIMUM_EGRESS_NETWORK_CONNECTORS} egress network connector labels are supported`, + }); + } else { + overrides.egressNetworkConnectors = [...connectors, value]; + } + break; + } + case 'image-arn': + if (!MICROVM_IMAGE_ARN_PATTERN.test(value)) { + violations.push({ label, reason: `'${value}' is not a valid customer MicroVM image ARN` }); + } else { + overrides.imageIdentifier = value; + } + break; + case 'image-version': + overrides.imageVersion = value; + break; + case 'maximum-duration-in-seconds': { + const duration = Number(value); + if (!Number.isInteger(duration) || duration < 1 || duration > MAXIMUM_DURATION_IN_SECONDS) { + violations.push({ + label, + reason: `maximum duration must be an integer between 1 and ${MAXIMUM_DURATION_IN_SECONDS}`, + }); + } else { + overrides.maximumDurationInSeconds = duration; + } + break; + } + default: + violations.push({ label, reason: `key '${key}' is not a supported MicroVM override` }); + } + } + + return { overrides, violations }; +} diff --git a/lambdas/libs/compute-providers/package.json b/lambdas/libs/compute-providers/package.json index 888b28ebce..b42f642545 100644 --- a/lambdas/libs/compute-providers/package.json +++ b/lambdas/libs/compute-providers/package.json @@ -11,7 +11,10 @@ "./aws/ec2/webhook": "./aws/ec2/webhook.ts", "./aws/ec2/control-plane": "./aws/ec2/control-plane.ts", "./aws/ec2/runners": "./aws/ec2/src/runners.ts", - "./aws/ec2/control-plane/runner-creation": "./aws/ec2/src/control-plane/runner-creation.ts" + "./aws/ec2/control-plane/runner-creation": "./aws/ec2/src/control-plane/runner-creation.ts", + "./aws/ec2/control-plane/runners": "./aws/ec2/src/control-plane/runners.ts", + "./aws/ec2/control-plane/runner-config": "./aws/ec2/src/control-plane/runner-config.ts", + "./aws/microvm/control-plane": "./aws/microvm/control-plane.ts" }, "type": "module", "license": "MIT", diff --git a/lambdas/libs/compute-providers/providers.config.control-plane.ts b/lambdas/libs/compute-providers/providers.config.control-plane.ts index 55ebaca95e..45a584bc06 100644 --- a/lambdas/libs/compute-providers/providers.config.control-plane.ts +++ b/lambdas/libs/compute-providers/providers.config.control-plane.ts @@ -1,5 +1,6 @@ import { provider as ec2 } from './aws/ec2/control-plane'; +import { provider as microvm } from './aws/microvm/control-plane'; import type { ControlPlaneProviderModule } from './contracts'; /** Provider plugins included in the control-plane bundle. */ -export const enabledControlPlaneProviders = [ec2] as const satisfies readonly ControlPlaneProviderModule[]; +export const enabledControlPlaneProviders = [ec2, microvm] as const satisfies readonly ControlPlaneProviderModule[]; From fd3c232f9fda83b26dec759b59edd9269ccab666 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 6 Aug 2026 20:42:21 +0200 Subject: [PATCH 26/54] feat(compute-providers): add MicroVM webhook routing --- .../src/webhook/dynamic-labels-policy.ts | 61 ++++++++++ .../src/webhook/dynamic-labels.test.ts | 105 ++++++++++++++++++ .../aws/microvm/src/webhook/dynamic-labels.ts | 48 ++++++++ .../aws/microvm/webhook.test.ts | 36 ++++++ .../compute-providers/aws/microvm/webhook.ts | 16 +++ lambdas/libs/compute-providers/package.json | 1 + .../providers.config.webhook.ts | 3 +- 7 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels-policy.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/webhook.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/webhook.ts diff --git a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels-policy.ts b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels-policy.ts new file mode 100644 index 0000000000..9785383727 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels-policy.ts @@ -0,0 +1,61 @@ +import type { AwsDynamicLabelsPolicy } from '../../../../contracts'; + +function globToRegExp(glob: string): RegExp { + const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); + return new RegExp(`^${pattern}$`); +} + +function matchesAny(value: string, patterns: string[] | undefined): boolean { + if (!patterns || patterns.length === 0) return false; + return patterns.some((pattern) => globToRegExp(pattern).test(value)); +} + +function evaluateLabel(label: string, policy: AwsDynamicLabelsPolicy, labelPrefix: string): string | null { + const stripped = label.slice(labelPrefix.length); + const colonIndex = stripped.indexOf(':'); + const key = colonIndex === -1 ? stripped : stripped.slice(0, colonIndex); + const value = colonIndex === -1 ? undefined : stripped.slice(colonIndex + 1); + + if (policy.blocked_keys?.includes(key)) { + return `key '${key}' is in blocked_keys`; + } + + const rule = policy.restricted_keys?.[key]; + if (!rule || value === undefined) return null; + + if (rule.allowed && rule.allowed.length > 0 && !matchesAny(value, rule.allowed)) { + return `value '${value}' not in allowed list`; + } + if (rule.denied && matchesAny(value, rule.denied)) { + return `value '${value}' in denied list`; + } + if (rule.max !== undefined && rule.max !== null) { + const valueNumber = Number(value); + const maximum = Number(rule.max); + if (!Number.isFinite(valueNumber) || !Number.isFinite(maximum)) { + return `max set but value '${value}' or max '${rule.max}' is not numeric`; + } + if (valueNumber > maximum) { + return `value '${value}' exceeds max '${rule.max}'`; + } + } + + return null; +} + +export function violationsAgainstAwsDynamicLabelsPolicy( + labels: string[], + policy: AwsDynamicLabelsPolicy | null | undefined, + labelPrefix: string, +): { label: string; reason: string }[] { + if (!policy) return []; + + const violations: { label: string; reason: string }[] = []; + for (const label of labels) { + if (!label.startsWith(labelPrefix)) continue; + const reason = evaluateLabel(label, policy, labelPrefix); + if (reason) violations.push({ label, reason }); + } + return violations; +} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts new file mode 100644 index 0000000000..6b157309af --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import type { RunnerMatcherConfig } from '../../../../contracts'; +import { microvmDynamicLabelProvider } from './dynamic-labels'; + +const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-large'; +const egressConnectorArn = 'arn:aws:lambda:eu-west-1:123456789012:network-connector:github-runner-private-egress'; + +describe('microvmDynamicLabelProvider', () => { + it('accepts supported MicroVM overrides', () => { + const queue = microvmQueue(); + const dynamicLabels = [ + `ghr-microvm-egress-network-connectors:${egressConnectorArn}`, + `ghr-microvm-image-arn:${imageArn}`, + 'ghr-microvm-image-version:3.0', + 'ghr-microvm-maximum-duration-in-seconds:7200', + ]; + + expect(selectQueue(queue, dynamicLabels)).toEqual({ + queue, + labels: ['self-hosted', 'linux', ...dynamicLabels], + }); + }); + + it('rejects dynamic labels when the queue disables them', () => { + const queue = microvmQueue(); + queue.matcherConfig.enableDynamicLabels = false; + + expect(selectQueue(queue, ['ghr-microvm-image-version:3.0'])).toBeUndefined(); + }); + + it('rejects unsupported MicroVM resource overrides', () => { + expect(selectQueue(microvmQueue(), ['ghr-microvm-memory:8192'])).toBeUndefined(); + }); + + it('enforces the AWS dynamic-label policy', () => { + const queue = microvmQueue(); + queue.matcherConfig.awsDynamicLabelsPolicy = { + restricted_keys: { 'maximum-duration-in-seconds': { max: 3600 } }, + }; + + expect(selectQueue(queue, ['ghr-microvm-maximum-duration-in-seconds:7200'])).toBeUndefined(); + }); + + it('applies allowed patterns to the complete image ARN', () => { + const queue = microvmQueue(); + queue.matcherConfig.awsDynamicLabelsPolicy = { + restricted_keys: { + 'image-arn': { + allowed: ['arn:aws:lambda:eu-west-1:123456789012:microvm-image:approved-*'], + }, + }, + }; + + expect( + selectQueue(queue, ['ghr-microvm-image-arn:arn:aws:lambda:eu-west-1:123456789012:microvm-image:approved-large']), + ).toBeDefined(); + expect( + selectQueue(queue, ['ghr-microvm-image-arn:arn:aws:lambda:eu-west-1:123456789012:microvm-image:unapproved']), + ).toBeUndefined(); + }); + + it('applies the policy to each egress connector label', () => { + const queue = microvmQueue(); + queue.matcherConfig.awsDynamicLabelsPolicy = { + restricted_keys: { + 'egress-network-connectors': { + allowed: ['arn:aws:lambda:eu-west-1:123456789012:network-connector:approved-*'], + }, + }, + }; + + expect( + selectQueue(queue, [ + 'ghr-microvm-egress-network-connectors:arn:aws:lambda:eu-west-1:123456789012:network-connector:approved-private', + ]), + ).toBeDefined(); + expect( + selectQueue(queue, [ + 'ghr-microvm-egress-network-connectors:arn:aws:lambda:eu-west-1:123456789012:network-connector:unapproved', + ]), + ).toBeUndefined(); + }); +}); + +function selectQueue(queue: RunnerMatcherConfig, sanitizedGhrLabels: string[]) { + return microvmDynamicLabelProvider.selectQueue({ + queue, + nonGhrLabels: ['self-hosted', 'linux'], + sanitizedGhrLabels, + }); +} + +function microvmQueue(): RunnerMatcherConfig { + return { + id: 'microvm', + arn: 'arn:aws:sqs:eu-west-1:123456789012:microvm', + computeProvider: 'microvm', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux', 'arm64', 'microvm']], + exactMatch: false, + enableDynamicLabels: true, + }, + }; +} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.ts b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.ts new file mode 100644 index 0000000000..36eb3e7670 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.ts @@ -0,0 +1,48 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; + +import type { DynamicLabelDispatchTarget, DynamicLabelProvider, RunnerMatcherConfig } from '../../../../contracts'; +import { MICROVM_DYNAMIC_LABEL_PREFIX, parseMicrovmDynamicLabels } from '../dynamic-labels'; +import { violationsAgainstAwsDynamicLabelsPolicy } from './dynamic-labels-policy'; + +const logger = createChildLogger('handler'); + +export function selectMicrovmDynamicLabelQueue( + matches: RunnerMatcherConfig[], + nonGhrLabels: string[], + sanitizedGhrLabels: string[], +): DynamicLabelDispatchTarget | undefined { + for (const queue of matches) { + if (!queue.matcherConfig.enableDynamicLabels) { + logger.warn(`Queue ${queue.id} matches non-dynamic labels but does not allow dynamic labels; trying next match`); + continue; + } + + const parsedLabels = parseMicrovmDynamicLabels(sanitizedGhrLabels); + const policyViolations = violationsAgainstAwsDynamicLabelsPolicy( + sanitizedGhrLabels, + queue.matcherConfig.awsDynamicLabelsPolicy, + MICROVM_DYNAMIC_LABEL_PREFIX, + ); + const violations = [...parsedLabels.violations, ...policyViolations]; + + if (violations.length === 0) { + return { + queue, + labels: [...nonGhrLabels, ...sanitizedGhrLabels], + }; + } + + for (const violation of violations) { + logger.warn( + `Queue ${queue.id}: dynamic label '${violation.label}' is not accepted (${violation.reason}); trying next match`, + ); + } + } + + return undefined; +} + +export const microvmDynamicLabelProvider: DynamicLabelProvider = { + selectQueue: ({ queue, nonGhrLabels, sanitizedGhrLabels }) => + selectMicrovmDynamicLabelQueue([queue], nonGhrLabels, sanitizedGhrLabels), +}; diff --git a/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts b/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts new file mode 100644 index 0000000000..bdc6d2918c --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import type { RunnerMatcherConfig } from '../../contracts'; +import { provider } from './webhook'; + +describe('MicroVM webhook provider contract', () => { + it('exposes MicroVM dynamic-label selection', () => { + const plugin = provider.createPlugin(); + const queue = microvmQueue(); + + expect(plugin.type).toBe('microvm'); + expect( + plugin.capabilities.dynamicLabels.selectQueue({ + queue, + nonGhrLabels: ['self-hosted', 'linux'], + sanitizedGhrLabels: ['ghr-microvm-image-version:3.0'], + }), + ).toEqual({ + queue, + labels: ['self-hosted', 'linux', 'ghr-microvm-image-version:3.0'], + }); + }); +}); + +function microvmQueue(): RunnerMatcherConfig { + return { + id: 'microvm', + arn: 'arn:aws:sqs:eu-west-1:123456789012:microvm', + computeProvider: 'microvm', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: true, + }, + }; +} diff --git a/lambdas/libs/compute-providers/aws/microvm/webhook.ts b/lambdas/libs/compute-providers/aws/microvm/webhook.ts new file mode 100644 index 0000000000..48d603e476 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/webhook.ts @@ -0,0 +1,16 @@ +import type { ComputeProviderPlugin } from '../../core'; + +import type { WebhookProviderCapabilities, WebhookProviderModule } from '../../contracts'; +import { microvmDynamicLabelProvider } from './src/webhook/dynamic-labels'; + +export function createMicrovmWebhookPlugin(): ComputeProviderPlugin { + return { + type: 'microvm', + capabilities: { dynamicLabels: microvmDynamicLabelProvider }, + }; +} + +export const provider = { + type: 'microvm', + createPlugin: createMicrovmWebhookPlugin, +} satisfies WebhookProviderModule<'microvm'>; diff --git a/lambdas/libs/compute-providers/package.json b/lambdas/libs/compute-providers/package.json index b42f642545..a1d0e293cf 100644 --- a/lambdas/libs/compute-providers/package.json +++ b/lambdas/libs/compute-providers/package.json @@ -14,6 +14,7 @@ "./aws/ec2/control-plane/runner-creation": "./aws/ec2/src/control-plane/runner-creation.ts", "./aws/ec2/control-plane/runners": "./aws/ec2/src/control-plane/runners.ts", "./aws/ec2/control-plane/runner-config": "./aws/ec2/src/control-plane/runner-config.ts", + "./aws/microvm/webhook": "./aws/microvm/webhook.ts", "./aws/microvm/control-plane": "./aws/microvm/control-plane.ts" }, "type": "module", diff --git a/lambdas/libs/compute-providers/providers.config.webhook.ts b/lambdas/libs/compute-providers/providers.config.webhook.ts index 19c92734da..a4aec0853a 100644 --- a/lambdas/libs/compute-providers/providers.config.webhook.ts +++ b/lambdas/libs/compute-providers/providers.config.webhook.ts @@ -1,5 +1,6 @@ import { provider as ec2 } from './aws/ec2/webhook'; +import { provider as microvm } from './aws/microvm/webhook'; import type { WebhookProviderModule } from './contracts'; /** Provider plugins included in the webhook bundle. */ -export const enabledWebhookProviders = [ec2] as const satisfies readonly WebhookProviderModule[]; +export const enabledWebhookProviders = [ec2, microvm] as const satisfies readonly WebhookProviderModule[]; From f10d414e2974b973e7f00dd7acbf3dfe8b9d8ce7 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 6 Aug 2026 20:42:41 +0200 Subject: [PATCH 27/54] docs(compute-providers): document Lambda MicroVM provider --- .../compute-providers/aws/microvm/README.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 lambdas/libs/compute-providers/aws/microvm/README.md diff --git a/lambdas/libs/compute-providers/aws/microvm/README.md b/lambdas/libs/compute-providers/aws/microvm/README.md new file mode 100644 index 0000000000..e729bd4383 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/README.md @@ -0,0 +1,70 @@ +# Lambda MicroVM compute provider + +This provider manages a compatible AWS Lambda MicroVM image through the control-plane Lambda. It currently supports ephemeral JIT runners only. + +The MicroVM image `/run` hook receives this `runHookPayload`: + +```json +{ + "version": 1, + "runnerConfigSsmPath": "/github-action-runners/example/token" +} +``` + +Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and terminate the MicroVM after the job completes. + +The control-plane Lambda requires these provider environment variables: + +- `MICROVM_IMAGE_ARN` +- `MICROVM_EXECUTION_ROLE_ARN` +- `MICROVM_IMAGE_VERSION` (optional) +- `MICROVM_INGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) +- `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) +- `MICROVM_MAXIMUM_DURATION_IN_SECONDS` (optional, defaults to 3600) +- `MICROVM_LOG_GROUP` (optional) + +## Dynamic labels + +When a runner matcher enables dynamic labels, workflow jobs can override the +following `RunMicrovm` inputs: + +| Label | Override | +| --------------------------------------------------- | ---------------------------------------------- | +| `ghr-microvm-egress-network-connectors:` | One egress network connector ARN | +| `ghr-microvm-image-arn:` | MicroVM image ARN | +| `ghr-microvm-image-version:` | MicroVM image version | +| `ghr-microvm-maximum-duration-in-seconds:` | Maximum lifetime from 1 through 28,800 seconds | + +Repeat `ghr-microvm-egress-network-connectors:` to attach multiple +connectors. Specify one ARN per label; `RunMicrovm` accepts at most 10. These +labels replace the compute provider's configured +`MICROVM_EGRESS_NETWORK_CONNECTORS` value for that job. + +Lambda MicroVM does not expose CPU or memory as `RunMicrovm` inputs. Select an +image and version with the required resources instead. Labels such as +`ghr-microvm-memory` are rejected. + +Execution roles, ingress network connectors, logging, idle policy, run hook +payloads, and client tokens remain deployment-controlled. Egress connector +overrides change the runner's network boundary and should be restricted to +approved connector ARNs with `awsDynamicLabelsPolicy`. + +Use the matcher's `awsDynamicLabelsPolicy` to restrict values accepted from +workflow jobs. The MicroVM policy keys are `egress-network-connectors`, +`image-arn`, `image-version`, and `maximum-duration-in-seconds`. For example: + +```json +{ + "restricted_keys": { + "egress-network-connectors": { + "allowed": ["arn:aws:lambda:eu-west-1:123456789012:network-connector:github-runner-*"] + }, + "image-arn": { + "allowed": ["arn:aws:lambda:eu-west-1:123456789012:microvm-image:github-runner-*"] + }, + "maximum-duration-in-seconds": { + "max": 3600 + } + } +} +``` From d3f8725150bd6b7ec8f689737238a06c31f6cc96 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 20:11:04 +0200 Subject: [PATCH 28/54] fix(compute-providers): replace unsupported MicroVM tags --- lambdas/libs/aws-ssm-util/src/index.test.ts | 77 +++- lambdas/libs/aws-ssm-util/src/index.ts | 54 ++- .../compute-providers/aws/microvm/README.md | 42 ++- .../microvm/src/control-plane/config.test.ts | 14 + .../aws/microvm/src/control-plane/config.ts | 10 + .../src/control-plane/microvms.test.ts | 269 ++++++++------ .../aws/microvm/src/control-plane/microvms.ts | 136 +++---- .../src/control-plane/runner-config.test.ts | 39 +- .../src/control-plane/runner-config.ts | 12 +- .../src/control-plane/runner-metadata.test.ts | 253 +++++++++++++ .../src/control-plane/runner-metadata.ts | 348 ++++++++++++++++++ .../src/control-plane/scale-down.test.ts | 48 +-- .../microvm/src/control-plane/scale-down.ts | 21 +- .../aws/microvm/src/environment.d.ts | 1 + .../src/webhook/dynamic-labels-policy.ts | 61 --- .../src/webhook/dynamic-labels.test.ts | 80 ++-- .../aws/microvm/src/webhook/dynamic-labels.ts | 62 ++-- .../aws/microvm/webhook.test.ts | 57 ++- 18 files changed, 1202 insertions(+), 382 deletions(-) create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts delete mode 100644 lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels-policy.ts diff --git a/lambdas/libs/aws-ssm-util/src/index.test.ts b/lambdas/libs/aws-ssm-util/src/index.test.ts index 8a1d8d3864..ad68c12279 100644 --- a/lambdas/libs/aws-ssm-util/src/index.test.ts +++ b/lambdas/libs/aws-ssm-util/src/index.test.ts @@ -1,6 +1,8 @@ import { + DeleteParameterCommand, GetParameterCommand, GetParameterCommandOutput, + GetParametersByPathCommand, GetParametersCommand, PutParameterCommand, PutParameterCommandOutput, @@ -10,7 +12,16 @@ import 'aws-sdk-client-mock-jest/vitest'; import { mockClient } from 'aws-sdk-client-mock'; import nock from 'nock'; -import { getParameter, getParameters, putParameter, resetSSMClient, ssmClient, SSM_ADVANCED_TIER_THRESHOLD } from '.'; +import { + deleteParameter, + getParameter, + getParameters, + getParametersByPath, + putParameter, + resetSSMClient, + ssmClient, + SSM_ADVANCED_TIER_THRESHOLD, +} from '.'; import { describe, it, expect, beforeEach, vi } from 'vitest'; const mockSSMClient = mockClient(SSMClient); @@ -104,6 +115,30 @@ describe('Test getParameter and putParameter', () => { }); }); + it('overwrites a parameter only when explicitly requested', async () => { + mockSSMClient.on(PutParameterCommand).resolves({}); + + await putParameter('testParam', 'updated', false, { overwrite: true }); + + expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, { + Name: 'testParam', + Value: 'updated', + Type: 'String', + Overwrite: true, + }); + }); + + it('rejects tags when overwriting an existing parameter', async () => { + mockSSMClient.resetHistory(); + await expect( + putParameter('testParam', 'updated', false, { + overwrite: true, + tags: [{ Key: 'owner', Value: 'runner' }], + } as never), + ).rejects.toThrow('tags cannot be supplied when overwriting'); + expect(mockSSMClient).not.toHaveReceivedCommand(PutParameterCommand); + }); + it('Puts parameters as SecureString', async () => { // Arrange const parameterValue = 'test'; @@ -256,6 +291,46 @@ describe('Test getParameters (batch)', () => { }); }); +describe('Test direct parameter path operations', () => { + beforeEach(() => { + mockSSMClient.reset(); + }); + + it('paginates direct, non-secret children of a parameter path', async () => { + mockSSMClient + .on(GetParametersByPathCommand, { + Path: '/metadata', + Recursive: false, + WithDecryption: false, + NextToken: undefined, + }) + .resolves({ Parameters: [{ Name: '/metadata/one', Value: '1' }], NextToken: 'page-2' }) + .on(GetParametersByPathCommand, { + Path: '/metadata', + Recursive: false, + WithDecryption: false, + NextToken: 'page-2', + }) + .resolves({ Parameters: [{ Name: '/metadata/two', Value: '2' }] }); + + await expect(getParametersByPath('/metadata')).resolves.toEqual( + new Map([ + ['/metadata/one', '1'], + ['/metadata/two', '2'], + ]), + ); + expect(mockSSMClient).toHaveReceivedCommandTimes(GetParametersByPathCommand, 2); + }); + + it('deletes an exact parameter name', async () => { + mockSSMClient.on(DeleteParameterCommand).resolves({}); + + await deleteParameter('/metadata/one'); + + expect(mockSSMClient).toHaveReceivedCommandWith(DeleteParameterCommand, { Name: '/metadata/one' }); + }); +}); + describe('SSM client configuration', () => { it('configures adaptive retry with a raised attempt cap', async () => { const config = ssmClient().config; diff --git a/lambdas/libs/aws-ssm-util/src/index.ts b/lambdas/libs/aws-ssm-util/src/index.ts index 71b33cbf41..9fef6c7b97 100644 --- a/lambdas/libs/aws-ssm-util/src/index.ts +++ b/lambdas/libs/aws-ssm-util/src/index.ts @@ -1,4 +1,11 @@ -import { GetParametersCommand, PutParameterCommand, SSMClient, Tag } from '@aws-sdk/client-ssm'; +import { + DeleteParameterCommand, + GetParametersByPathCommand, + GetParametersCommand, + PutParameterCommand, + SSMClient, + Tag, +} from '@aws-sdk/client-ssm'; import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; import { SSMProvider } from '@aws-lambda-powertools/parameters/ssm'; @@ -103,14 +110,56 @@ export async function getParameters(parameter_names: string[]): Promise> { + const result = new Map(); + let nextToken: string | undefined; + + do { + const response = await ssmClient().send( + new GetParametersByPathCommand({ + Path: parameter_path, + Recursive: false, + WithDecryption: false, + NextToken: nextToken, + }), + ); + + for (const parameter of response.Parameters ?? []) { + if (parameter.Name && parameter.Value) { + result.set(parameter.Name, parameter.Value); + } + } + nextToken = response.NextToken; + } while (nextToken); + + return result; +} + +export async function deleteParameter(parameter_name: string): Promise { + await ssmClient().send(new DeleteParameterCommand({ Name: parameter_name })); +} + export const SSM_ADVANCED_TIER_THRESHOLD = 4000; +type PutParameterOptions = { overwrite: true; tags?: never } | { overwrite?: false | undefined; tags?: Tag[] }; + export async function putParameter( parameter_name: string, parameter_value: string, secure: boolean, - options: { tags?: Tag[] } = {}, + options: PutParameterOptions = {}, ): Promise { + if (options.overwrite && options.tags !== undefined) { + throw new Error('SSM parameter tags cannot be supplied when overwriting an existing parameter'); + } + const client = ssmClient(); // Determine tier based on parameter_value size @@ -121,6 +170,7 @@ export async function putParameter( Name: parameter_name, Value: parameter_value, Type: secure ? 'SecureString' : 'String', + Overwrite: options.overwrite, Tags: options.tags, Tier: valueSizeBytes >= SSM_ADVANCED_TIER_THRESHOLD ? 'Advanced' : 'Standard', }), diff --git a/lambdas/libs/compute-providers/aws/microvm/README.md b/lambdas/libs/compute-providers/aws/microvm/README.md index e729bd4383..71e53f9f7b 100644 --- a/lambdas/libs/compute-providers/aws/microvm/README.md +++ b/lambdas/libs/compute-providers/aws/microvm/README.md @@ -13,6 +13,15 @@ The MicroVM image `/run` hook receives this `runHookPayload`: Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and terminate the MicroVM after the job completes. +Runner ownership and lifecycle state are stored separately as non-secret `String` +parameters under `/`. The immutable base +record and independent state parameters prevent concurrent GitHub ID, orphan, +and cleanup updates from overwriting one another. Deleting the JIT SecureString +does not delete this metadata. Use a dedicated metadata prefix that does not +overlap the JIT path, and do not grant the MicroVM execution role access to it. +The control plane retries pending cleanup, removes metadata after termination, +and reconciles expired records during inventory. + The control-plane Lambda requires these provider environment variables: - `MICROVM_IMAGE_ARN` @@ -21,8 +30,31 @@ The control-plane Lambda requires these provider environment variables: - `MICROVM_INGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) - `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) - `MICROVM_MAXIMUM_DURATION_IN_SECONDS` (optional, defaults to 3600) +- `MICROVM_METADATA_SSM_PATH` (dedicated SSM path for control-plane metadata) - `MICROVM_LOG_GROUP` (optional) +The control-plane role requires `ssm:GetParametersByPath`, `ssm:PutParameter`, +and `ssm:DeleteParameter` on the dedicated metadata prefix, plus +`lambda:ListMicrovms`, `lambda:RunMicrovm`, and `lambda:TerminateMicrovm` for +inventory and lifecycle reconciliation. Restrict `lambda:RunMicrovm` and +`lambda:TerminateMicrovm` to approved image resources; `lambda:ListMicrovms` +does not support resource-level permissions. + +The MicroVM execution role must trust `lambda.amazonaws.com` for both +`sts:AssumeRole` and `sts:TagSession`. Restrict `iam:PassRole` to that exact role +with `iam:PassedToService=lambda.amazonaws.com`. Egress connectors also require +`lambda:PassNetworkConnector`; because that action does not currently support +resource-level permissions, enforce the connector boundary with the explicit +dynamic-label allowlist described below. + +All MicroVMs using one execution role and JIT prefix share a trust boundary. +Grant that role only `ssm:GetParameter` and `ssm:DeleteParameter` on the JIT +prefix; do not grant parameter-listing APIs or access to the metadata prefix. +The `MicrovmId` tag on each JIT parameter supports operations but is not a +documented binding to the calling MicroVM's session identity. Only allow trusted +images and workloads within a shared role, or isolate trust domains with +separate roles, prefixes, and provider deployments. + ## Dynamic labels When a runner matcher enables dynamic labels, workflow jobs can override the @@ -45,9 +77,10 @@ image and version with the required resources instead. Labels such as `ghr-microvm-memory` are rejected. Execution roles, ingress network connectors, logging, idle policy, run hook -payloads, and client tokens remain deployment-controlled. Egress connector -overrides change the runner's network boundary and should be restricted to -approved connector ARNs with `awsDynamicLabelsPolicy`. +payloads, and client tokens remain deployment-controlled. Image ARN, image +version, and egress connector overrides change executable code or the network +boundary, so they are rejected unless `awsDynamicLabelsPolicy` supplies an +explicit `allowed` list for the corresponding key. Use the matcher's `awsDynamicLabelsPolicy` to restrict values accepted from workflow jobs. The MicroVM policy keys are `egress-network-connectors`, @@ -62,6 +95,9 @@ workflow jobs. The MicroVM policy keys are `egress-network-connectors`, "image-arn": { "allowed": ["arn:aws:lambda:eu-west-1:123456789012:microvm-image:github-runner-*"] }, + "image-version": { + "allowed": ["3.*"] + }, "maximum-duration-in-seconds": { "max": 3600 } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts index ce692a1ab4..1cc240a9f7 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts @@ -8,6 +8,7 @@ beforeEach(() => { process.env = { ...cleanEnv }; process.env.MICROVM_IMAGE_ARN = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/microvm-runner'; + process.env.MICROVM_METADATA_SSM_PATH = '/github-action-runners/unit-test/microvm-metadata/'; delete process.env.MICROVM_IMAGE_VERSION; delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS; delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS; @@ -24,6 +25,7 @@ describe('loadMicrovmProviderConfig', () => { ingressNetworkConnectors: undefined, egressNetworkConnectors: undefined, maximumDurationInSeconds: 3600, + metadataSsmPath: '/github-action-runners/unit-test/microvm-metadata', logging: undefined, }); }); @@ -47,6 +49,7 @@ describe('loadMicrovmProviderConfig', () => { it.each([ ['MICROVM_IMAGE_ARN', 'MICROVM_IMAGE_ARN'], ['MICROVM_EXECUTION_ROLE_ARN', 'MICROVM_EXECUTION_ROLE_ARN'], + ['MICROVM_METADATA_SSM_PATH', 'MICROVM_METADATA_SSM_PATH'], ])('requires %s', (environmentVariable, expectedName) => { delete process.env[environmentVariable]; @@ -68,4 +71,15 @@ describe('loadMicrovmProviderConfig', () => { expect(() => loadMicrovmProviderConfig()).toThrow(/MICROVM_EGRESS_NETWORK_CONNECTORS must/); }); + + it.each(['metadata', '/', '/metadata//nested', '/metadata/has space'])( + 'rejects malformed metadata SSM path %s', + (metadataPath) => { + process.env.MICROVM_METADATA_SSM_PATH = metadataPath; + + expect(() => loadMicrovmProviderConfig()).toThrow( + 'MICROVM_METADATA_SSM_PATH must be a valid absolute SSM parameter path', + ); + }, + ); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts index 7c0a7662b9..ddd7891362 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts @@ -11,6 +11,7 @@ export interface MicrovmProviderConfig { ingressNetworkConnectors?: string[]; logging?: Logging; maximumDurationInSeconds: number; + metadataSsmPath: string; } function requiredEnvironmentValue(name: string, value: string | undefined): string { @@ -26,6 +27,14 @@ function optionalEnvironmentValue(value: string | undefined): string | undefined return trimmed ? trimmed : undefined; } +function parseMetadataSsmPath(value: string | undefined): string { + const path = requiredEnvironmentValue('MICROVM_METADATA_SSM_PATH', value).replace(/\/+$/, ''); + if (path === '' || !/^\/[A-Za-z0-9_.\-/]+$/.test(path) || path.includes('//')) { + throw new Error('MICROVM_METADATA_SSM_PATH must be a valid absolute SSM parameter path'); + } + return path; +} + function parseNetworkConnectors(name: string, value: string | undefined): string[] | undefined { const configuredValue = optionalEnvironmentValue(value); if (!configuredValue) return undefined; @@ -83,6 +92,7 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig { process.env.MICROVM_EGRESS_NETWORK_CONNECTORS, ), maximumDurationInSeconds: parseMaximumDuration(process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS), + metadataSsmPath: parseMetadataSsmPath(process.env.MICROVM_METADATA_SSM_PATH), logging: logGroup ? ({ cloudWatch: { logGroup } } satisfies RunMicrovmCommandInput['logging']) : undefined, }; } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts index 7d7199a3cc..cd4fe86250 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts @@ -1,11 +1,8 @@ import { LambdaMicrovmsClient, ListMicrovmsCommand, - ListTagsCommand, RunMicrovmCommand, - TagResourceCommand, TerminateMicrovmCommand, - UntagResourceCommand, } from '@aws-sdk/client-lambda-microvms'; import { mockClient } from 'aws-sdk-client-mock'; import 'aws-sdk-client-mock-jest/vitest'; @@ -15,49 +12,69 @@ import type { MicrovmProviderConfig } from './config'; import { isRetryableMicrovmError, listMicrovmRunners, - microvmArn, microvmBootTimeExceeded, runMicrovmRunner, - tagMicrovm, terminateMicrovm, - untagMicrovm, } from './microvms'; +import { + createMicrovmRunnerMetadata, + deleteMicrovmRunnerMetadata, + listMicrovmRunnerMetadata, + markMicrovmCleanupPending, + type MicrovmRunnerMetadata, +} from './runner-metadata'; + +vi.mock('./runner-metadata', () => ({ + createMicrovmRunnerMetadata: vi.fn(), + deleteMicrovmRunnerMetadata: vi.fn(), + listMicrovmRunnerMetadata: vi.fn(), + markMicrovmCleanupPending: vi.fn(), +})); const mockMicrovmClient = mockClient(LambdaMicrovmsClient); const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; +const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; const config: MicrovmProviderConfig = { imageIdentifier: imageArn, imageVersion: '3.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', egressNetworkConnectors: ['arn:egress'], maximumDurationInSeconds: 1200, + metadataSsmPath, logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, }; +function metadata(overrides: Partial = {}): MicrovmRunnerMetadata { + return { + version: 1, + microvmId: 'mvm-managed', + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'scale-up-lambda', + imageArn, + imageVersion: '3.0', + createdAt: '2026-08-06T10:00:00.000Z', + expiresAt: '2026-08-06T11:00:00.000Z', + ...overrides, + }; +} + beforeEach(() => { mockMicrovmClient.reset(); + vi.clearAllMocks(); vi.useRealTimers(); process.env.AWS_REGION = 'eu-west-1'; process.env.RUNNER_BOOT_TIME_IN_MINUTES = '5'; -}); - -describe('microvmArn', () => { - it('derives the MicroVM resource ARN from its image ARN', () => { - expect(microvmArn(imageArn, 'mvm-123')).toBe('arn:aws:lambda:eu-west-1:123456789012:microvm:mvm-123'); - expect(microvmArn(imageArn.replace('arn:aws:', 'arn:aws-us-gov:'), 'mvm-456')).toContain('arn:aws-us-gov:lambda:'); - }); - - it('rejects image names that cannot identify a customer MicroVM resource', () => { - expect(() => microvmArn('runner', 'mvm-123')).toThrow( - 'MICROVM_IMAGE_ARN is not a valid customer MicroVM image ARN', - ); - }); + vi.mocked(createMicrovmRunnerMetadata).mockResolvedValue(); + vi.mocked(deleteMicrovmRunnerMetadata).mockResolvedValue(); + vi.mocked(listMicrovmRunnerMetadata).mockResolvedValue({ cleanupMicrovmIds: [], metadataById: new Map() }); + vi.mocked(markMicrovmCleanupPending).mockResolvedValue(); }); describe('runMicrovmRunner', () => { - it('launches and tags a managed runner', async () => { - mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-123' }); - mockMicrovmClient.on(TagResourceCommand).resolves({}); + it('launches a runner and records durable ownership metadata', async () => { + mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-123', imageArn }); await expect( runMicrovmRunner({ @@ -80,15 +97,15 @@ describe('runMicrovmRunner', () => { runHookPayload: '{"version":1}', clientToken: expect.any(String), }); - expect(mockMicrovmClient).toHaveReceivedCommandWith(TagResourceCommand, { - Resource: microvmArn(imageArn, 'mvm-123'), - Tags: { - 'ghr:Application': 'github-action-runner', - 'ghr:created_by': 'scale-up-lambda', - 'ghr:environment': 'unit-test', - 'ghr:Owner': 'Codertocat', - 'ghr:Type': 'Org', - }, + expect(createMicrovmRunnerMetadata).toHaveBeenCalledWith(metadataSsmPath, { + microvmId: 'mvm-123', + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'scale-up-lambda', + imageArn, + imageVersion: '3.0', + maximumDurationInSeconds: 1200, }); }); @@ -107,11 +124,10 @@ describe('runMicrovmRunner', () => { ).rejects.toThrow('RunMicrovm returned no microvmId'); }); - it('terminates a new runner when required tags cannot be applied', async () => { - const tagError = new Error('tag failed'); - mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-untagged' }); - mockMicrovmClient.on(TagResourceCommand).rejects(tagError); + it('terminates a new runner when required metadata cannot be recorded', async () => { + mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-untracked', imageArn }); mockMicrovmClient.on(TerminateMicrovmCommand).resolves({}); + vi.mocked(createMicrovmRunnerMetadata).mockRejectedValue(new Error('metadata failed')); await expect( runMicrovmRunner({ @@ -122,17 +138,17 @@ describe('runMicrovmRunner', () => { runnerType: 'Org', source: 'scale-up-lambda', }), - ).rejects.toThrow('tag failed'); + ).rejects.toThrow('metadata failed'); expect(mockMicrovmClient).toHaveReceivedCommandWith(TerminateMicrovmCommand, { - microvmIdentifier: 'mvm-untagged', + microvmIdentifier: 'mvm-untracked', }); }); - it('preserves the tag error when cleanup also fails', async () => { - mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-untagged' }); - mockMicrovmClient.on(TagResourceCommand).rejects(new Error('tag failed')); + it('preserves the metadata error when termination also fails', async () => { + mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-untracked', imageArn }); mockMicrovmClient.on(TerminateMicrovmCommand).rejects(new Error('terminate failed')); + vi.mocked(createMicrovmRunnerMetadata).mockRejectedValue(new Error('metadata failed')); await expect( runMicrovmRunner({ @@ -143,12 +159,13 @@ describe('runMicrovmRunner', () => { runnerType: 'Org', source: 'scale-up-lambda', }), - ).rejects.toThrow('tag failed'); + ).rejects.toThrow('metadata failed'); + expect(markMicrovmCleanupPending).toHaveBeenCalledWith(metadataSsmPath, 'mvm-untracked'); }); }); describe('listMicrovmRunners', () => { - it('paginates active MicroVMs and filters them by management tags', async () => { + it('paginates active MicroVMs and filters them by durable metadata', async () => { const startedAt = new Date('2026-08-06T10:00:00.000Z'); mockMicrovmClient .on(ListMicrovmsCommand) @@ -162,26 +179,23 @@ describe('listMicrovmRunners', () => { .resolvesOnce({ items: [{ microvmId: 'mvm-other', imageArn, imageVersion: '3.0', startedAt, state: 'PENDING' }], }); - mockMicrovmClient - .on(ListTagsCommand) - .resolvesOnce({ - Tags: { - 'ghr:Application': 'github-action-runner', - 'ghr:environment': 'unit-test', - 'ghr:Owner': 'Codertocat', - 'ghr:Type': 'Org', - 'ghr:github_runner_id': '42', - 'ghr:bypass-removal': 'true', - }, - }) - .resolvesOnce({ Tags: { 'ghr:Application': 'another-application' } }); + vi.mocked(listMicrovmRunnerMetadata).mockResolvedValue({ + cleanupMicrovmIds: [], + metadataById: new Map([ + ['mvm-managed', metadata({ githubRunnerId: '42', bypassRemoval: true })], + ['mvm-other', metadata({ microvmId: 'mvm-other', runnerOwner: 'Other' })], + ]), + }); await expect( - listMicrovmRunners({ - environment: 'unit-test', - runnerOwner: 'Codertocat', - runnerType: 'Org', - }), + listMicrovmRunners( + { + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org', + }, + metadataSsmPath, + ), ).resolves.toEqual([ { id: 'mvm-managed', @@ -200,9 +214,17 @@ describe('listMicrovmRunners', () => { maxResults: 50, nextToken: 'page-2', }); + expect(listMicrovmRunnerMetadata).toHaveBeenCalledWith( + metadataSsmPath, + new Map([ + ['mvm-managed', 'RUNNING'], + ['mvm-terminated', 'TERMINATED'], + ['mvm-other', 'PENDING'], + ]), + ); }); - it('applies environment, owner, type, and orphan filters after loading tags', async () => { + it('applies environment, owner, type, and orphan filters after loading metadata', async () => { mockMicrovmClient.on(ListMicrovmsCommand).resolves({ items: [ { @@ -214,62 +236,98 @@ describe('listMicrovmRunners', () => { }, ], }); - mockMicrovmClient.on(ListTagsCommand).resolves({ - Tags: { - 'ghr:Application': 'github-action-runner', - 'ghr:environment': 'other', - 'ghr:Owner': 'Other', - 'ghr:Type': 'Repo', - }, + vi.mocked(listMicrovmRunnerMetadata).mockResolvedValue({ + cleanupMicrovmIds: [], + metadataById: new Map([ + [ + 'mvm-filtered', + metadata({ microvmId: 'mvm-filtered', environment: 'other', runnerOwner: 'Other', runnerType: 'Repo' }), + ], + ]), }); - await expect(listMicrovmRunners({ environment: 'unit-test' })).resolves.toEqual([]); - await expect(listMicrovmRunners({ runnerOwner: 'Codertocat' })).resolves.toEqual([]); - await expect(listMicrovmRunners({ runnerType: 'Org' })).resolves.toEqual([]); - await expect(listMicrovmRunners({ orphan: true })).resolves.toEqual([]); + await expect(listMicrovmRunners({ environment: 'unit-test' }, metadataSsmPath)).resolves.toEqual([]); + await expect(listMicrovmRunners({ runnerOwner: 'Codertocat' }, metadataSsmPath)).resolves.toEqual([]); + await expect(listMicrovmRunners({ runnerType: 'Org' }, metadataSsmPath)).resolves.toEqual([]); + await expect(listMicrovmRunners({ orphan: true }, metadataSsmPath)).resolves.toEqual([]); }); - it('skips a MicroVM that terminates before its tags can be read', async () => { - const resourceNotFound = Object.assign(new Error('gone'), { name: 'ResourceNotFoundException' }); + it('fails closed for an image mismatch while ignoring unowned MicroVMs', async () => { mockMicrovmClient.on(ListMicrovmsCommand).resolves({ - items: [{ microvmId: 'mvm-gone', imageArn, imageVersion: '3.0', startedAt: new Date(), state: 'RUNNING' }], + items: [ + { microvmId: 'mvm-missing', imageArn, imageVersion: '3.0', state: 'RUNNING' }, + { microvmId: 'mvm-mismatch', imageArn, imageVersion: '3.0', state: 'RUNNING' }, + ], + }); + vi.mocked(listMicrovmRunnerMetadata).mockResolvedValue({ + cleanupMicrovmIds: [], + metadataById: new Map([ + ['mvm-mismatch', metadata({ microvmId: 'mvm-mismatch', imageArn: imageArn.replace(':runner', ':other') })], + ]), }); - mockMicrovmClient.on(ListTagsCommand).rejects(resourceNotFound); - await expect(listMicrovmRunners()).resolves.toEqual([]); + await expect(listMicrovmRunners({}, metadataSsmPath)).rejects.toThrow('does not match its metadata'); + }); + + it('attempts every pending cleanup and fails inventory closed when a retry fails', async () => { + const cleanupFailure = new Error('cleanup failed'); + mockMicrovmClient.on(ListMicrovmsCommand).resolves({ + items: [ + { microvmId: 'mvm-first', imageArn, imageVersion: '3.0', state: 'RUNNING' }, + { microvmId: 'mvm-second', imageArn, imageVersion: '3.0', state: 'PENDING' }, + ], + }); + mockMicrovmClient.on(TerminateMicrovmCommand, { microvmIdentifier: 'mvm-first' }).rejects(cleanupFailure); + mockMicrovmClient.on(TerminateMicrovmCommand, { microvmIdentifier: 'mvm-second' }).resolves({}); + vi.mocked(listMicrovmRunnerMetadata).mockResolvedValue({ + cleanupMicrovmIds: ['mvm-first', 'mvm-second'], + metadataById: new Map(), + }); + + await expect(listMicrovmRunners({}, metadataSsmPath)).rejects.toThrow('cleanup failed'); + expect(mockMicrovmClient).toHaveReceivedCommandWith(TerminateMicrovmCommand, { + microvmIdentifier: 'mvm-first', + }); + expect(mockMicrovmClient).toHaveReceivedCommandWith(TerminateMicrovmCommand, { + microvmIdentifier: 'mvm-second', + }); + expect(markMicrovmCleanupPending).toHaveBeenCalledTimes(2); }); - it('surfaces unexpected tag lookup failures', async () => { + it('surfaces metadata lookup failures instead of reporting zero runners', async () => { mockMicrovmClient.on(ListMicrovmsCommand).resolves({ - items: [{ microvmId: 'mvm-error', imageArn, imageVersion: '3.0', startedAt: new Date(), state: 'RUNNING' }], + items: [{ microvmId: 'mvm-error', imageArn, imageVersion: '3.0', state: 'RUNNING' }], }); - mockMicrovmClient.on(ListTagsCommand).rejects(new Error('list tags failed')); + vi.mocked(listMicrovmRunnerMetadata).mockRejectedValue(new Error('AccessDenied')); - await expect(listMicrovmRunners()).rejects.toThrow('list tags failed'); + await expect(listMicrovmRunners({}, metadataSsmPath)).rejects.toThrow('AccessDenied'); }); }); describe('MicroVM lifecycle helpers', () => { - it('tags, untags, and terminates a MicroVM', async () => { - mockMicrovmClient.on(TagResourceCommand).resolves({}); - mockMicrovmClient.on(UntagResourceCommand).resolves({}); + it('retains metadata until inventory observes a terminated MicroVM', async () => { mockMicrovmClient.on(TerminateMicrovmCommand).resolves({}); - await tagMicrovm(imageArn, 'mvm-123', { key: 'value' }); - await untagMicrovm(imageArn, 'mvm-123', ['key']); - await terminateMicrovm('mvm-123'); + await terminateMicrovm('mvm-123', metadataSsmPath); - expect(mockMicrovmClient).toHaveReceivedCommandWith(TagResourceCommand, { - Resource: microvmArn(imageArn, 'mvm-123'), - Tags: { key: 'value' }, - }); - expect(mockMicrovmClient).toHaveReceivedCommandWith(UntagResourceCommand, { - Resource: microvmArn(imageArn, 'mvm-123'), - TagKeys: ['key'], - }); - expect(mockMicrovmClient).toHaveReceivedCommandWith(TerminateMicrovmCommand, { - microvmIdentifier: 'mvm-123', - }); + expect(markMicrovmCleanupPending).toHaveBeenCalledWith(metadataSsmPath, 'mvm-123'); + expect(deleteMicrovmRunnerMetadata).not.toHaveBeenCalled(); + }); + + it('treats an already terminated MicroVM as successful cleanup', async () => { + const notFound = Object.assign(new Error('gone'), { name: 'ResourceNotFoundException' }); + mockMicrovmClient.on(TerminateMicrovmCommand).rejects(notFound); + + await expect(terminateMicrovm('mvm-gone', metadataSsmPath)).resolves.toBeUndefined(); + expect(deleteMicrovmRunnerMetadata).toHaveBeenCalledWith(metadataSsmPath, 'mvm-gone'); + }); + + it('retains metadata and marks cleanup pending when termination fails', async () => { + mockMicrovmClient.on(TerminateMicrovmCommand).rejects(new Error('terminate failed')); + + await expect(terminateMicrovm('mvm-123', metadataSsmPath)).rejects.toThrow('terminate failed'); + expect(markMicrovmCleanupPending).toHaveBeenCalledWith(metadataSsmPath, 'mvm-123'); + expect(deleteMicrovmRunnerMetadata).not.toHaveBeenCalled(); }); it('evaluates the configured boot window', () => { @@ -283,12 +341,15 @@ describe('MicroVM lifecycle helpers', () => { }); describe('isRetryableMicrovmError', () => { - it.each(['ConflictException', 'InternalServerException', 'ServiceQuotaExceededException', 'ThrottlingException'])( - 'classifies %s as retryable', - (name) => { - expect(isRetryableMicrovmError(Object.assign(new Error(name), { name }))).toBe(true); - }, - ); + it.each([ + 'ConflictException', + 'InternalServerException', + 'ServiceQuotaExceededException', + 'ThrottlingException', + 'TooManyUpdates', + ])('classifies %s as retryable', (name) => { + expect(isRetryableMicrovmError(Object.assign(new Error(name), { name }))).toBe(true); + }); it('classifies server, throttling, network, and nested failures as retryable', () => { expect(isRetryableMicrovmError(Object.assign(new Error('server'), { $fault: 'server' }))).toBe(true); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index edc4a3775b..3769d487c7 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -4,21 +4,22 @@ import { createChildLogger, getTracedAWSV3Client } from '@aws-github-runner/aws- import { LambdaMicrovmsClient, ListMicrovmsCommand, - ListTagsCommand, RunMicrovmCommand, - TagResourceCommand, TerminateMicrovmCommand, - UntagResourceCommand, } from '@aws-sdk/client-lambda-microvms'; import type { MicrovmItem, MicrovmState, RunMicrovmCommandInput } from '@aws-sdk/client-lambda-microvms'; import type { LambdaRunnerSource, ListRunnerFilters, RunnerInfo, RunnerType } from '../../../../core'; -import type { MicrovmProviderConfig } from './config'; +import { loadMicrovmProviderConfig, type MicrovmProviderConfig } from './config'; +import { + createMicrovmRunnerMetadata, + deleteMicrovmRunnerMetadata, + listMicrovmRunnerMetadata, + markMicrovmCleanupPending, +} from './runner-metadata'; const logger = createChildLogger('microvm-runners'); -const APPLICATION_TAG = 'ghr:Application'; -const APPLICATION_TAG_VALUE = 'github-action-runner'; const ACTIVE_STATES = new Set(['PENDING', 'RUNNING', 'SUSPENDING', 'SUSPENDED']); export interface MicrovmRunnerInfo extends RunnerInfo { @@ -52,6 +53,7 @@ const RETRYABLE_ERROR_NAMES = new Set([ 'ServiceQuotaExceededException', 'Throttling', 'ThrottlingException', + 'TooManyUpdates', 'TooManyRequestsException', ]); @@ -68,16 +70,6 @@ function microvmClient(): LambdaMicrovmsClient { return getTracedAWSV3Client(new LambdaMicrovmsClient({ region: process.env.AWS_REGION })); } -export function microvmArn(imageArn: string, microvmId: string): string { - const match = /^arn:([^:]+):lambda:([^:]+):([0-9]{12}):microvm-image:.+$/.exec(imageArn); - if (!match) { - throw new Error(`MICROVM_IMAGE_ARN is not a valid customer MicroVM image ARN: ${imageArn}`); - } - - const [, partition, region, accountId] = match; - return `arn:${partition}:lambda:${region}:${accountId}:microvm:${microvmId}`; -} - export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { const commandInput: RunMicrovmCommandInput = { imageIdentifier: input.config.imageIdentifier, @@ -103,17 +95,22 @@ export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { - logger.error(`Failed to terminate untagged MicroVM runner '${response.microvmId}'`, { + logger.error(`Failed to record metadata for new MicroVM runner '${response.microvmId}', terminating it`, { + error, + }); + await terminateMicrovm(response.microvmId, input.config.metadataSsmPath).catch((terminationError) => { + logger.error(`Failed to terminate untracked MicroVM runner '${response.microvmId}'`, { error: terminationError, }); }); @@ -123,7 +120,10 @@ export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { +export async function listMicrovmRunners( + filters: ListRunnerFilters = {}, + metadataSsmPath = loadMicrovmProviderConfig().metadataSsmPath, +): Promise { const client = microvmClient(); const items: MicrovmItem[] = []; let nextToken: string | undefined; @@ -139,34 +139,50 @@ export async function listMicrovmRunners(filters: ListRunnerFilters = {}): Promi nextToken = response.nextToken; } while (nextToken); - const runners: MicrovmRunnerInfo[] = []; - for (const item of items) { - if (!item.microvmId || !item.imageArn || !item.state || !ACTIVE_STATES.has(item.state)) continue; + const activeItems = items.filter( + (item): item is MicrovmItem & { imageArn: string; microvmId: string; state: MicrovmState } => + Boolean(item.microvmId && item.imageArn && item.state && ACTIVE_STATES.has(item.state)), + ); + const microvmStates = new Map( + items.flatMap((item) => (item.microvmId && item.state ? [[item.microvmId, item.state] as const] : [])), + ); + const { cleanupMicrovmIds, metadataById } = await listMicrovmRunnerMetadata(metadataSsmPath, microvmStates); - let tags: Record; + let cleanupError: unknown; + for (const microvmId of cleanupMicrovmIds) { + logger.warn(`Retrying cleanup of MicroVM runner '${microvmId}'`); try { - tags = - (await client.send(new ListTagsCommand({ Resource: microvmArn(item.imageArn, item.microvmId) }))).Tags ?? {}; + await terminateMicrovm(microvmId, metadataSsmPath); } catch (error) { - if (error instanceof Error && error.name === 'ResourceNotFoundException') continue; - throw error; + cleanupError ??= error; + logger.error(`Failed to retry cleanup of MicroVM runner '${microvmId}'`, { error }); } + } + if (cleanupError !== undefined) throw cleanupError; - if (tags[APPLICATION_TAG] !== APPLICATION_TAG_VALUE) continue; - if (filters.environment !== undefined && tags['ghr:environment'] !== filters.environment) continue; - if (filters.runnerType !== undefined && tags['ghr:Type'] !== filters.runnerType) continue; - if (filters.runnerOwner !== undefined && tags['ghr:Owner'] !== filters.runnerOwner) continue; - if (filters.orphan && tags['ghr:orphan'] !== 'true') continue; + const runners: MicrovmRunnerInfo[] = []; + for (const item of activeItems) { + const metadata = metadataById.get(item.microvmId); + if (!metadata) continue; + if (metadata.imageArn !== item.imageArn) { + throw new Error(`Active MicroVM runner '${item.microvmId}' has an image that does not match its metadata`); + } + + const orphan = Boolean(metadata.orphan); + if (filters.environment !== undefined && metadata.environment !== filters.environment) continue; + if (filters.runnerType !== undefined && metadata.runnerType !== filters.runnerType) continue; + if (filters.runnerOwner !== undefined && metadata.runnerOwner !== filters.runnerOwner) continue; + if (filters.orphan && !orphan) continue; runners.push({ id: item.microvmId, imageArn: item.imageArn, launchTime: item.startedAt, - owner: tags['ghr:Owner'], - type: tags['ghr:Type'] as RunnerInfo['type'], - orphan: tags['ghr:orphan'] === 'true', - githubRunnerId: tags['ghr:github_runner_id'], - bypassRemoval: tags['ghr:bypass-removal'] === 'true', + owner: metadata.runnerOwner, + type: metadata.runnerType, + orphan, + githubRunnerId: metadata.githubRunnerId, + bypassRemoval: metadata.bypassRemoval ?? false, state: item.state, }); } @@ -174,26 +190,22 @@ export async function listMicrovmRunners(filters: ListRunnerFilters = {}): Promi return runners; } -export async function tagMicrovm(imageArn: string, microvmId: string, tags: Record): Promise { - await microvmClient().send( - new TagResourceCommand({ - Resource: microvmArn(imageArn, microvmId), - Tags: tags, - }), - ); -} +export async function terminateMicrovm(microvmId: string, metadataSsmPath: string): Promise { + try { + await microvmClient().send(new TerminateMicrovmCommand({ microvmIdentifier: microvmId })); + } catch (error) { + if (error instanceof Error && error.name === 'ResourceNotFoundException') { + await deleteMicrovmRunnerMetadata(metadataSsmPath, microvmId); + return; + } -export async function untagMicrovm(imageArn: string, microvmId: string, tagKeys: string[]): Promise { - await microvmClient().send( - new UntagResourceCommand({ - Resource: microvmArn(imageArn, microvmId), - TagKeys: tagKeys, - }), - ); -} + await markMicrovmCleanupPending(metadataSsmPath, microvmId).catch((metadataError) => { + logger.error(`Failed to mark MicroVM runner '${microvmId}' for cleanup`, { error: metadataError }); + }); + throw error; + } -export async function terminateMicrovm(microvmId: string): Promise { - await microvmClient().send(new TerminateMicrovmCommand({ microvmIdentifier: microvmId })); + await markMicrovmCleanupPending(metadataSsmPath, microvmId); } export function microvmBootTimeExceeded(runner: { launchTime?: Date }): boolean { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts index 84afb5be3b..2d4252ac71 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts @@ -3,18 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CreateGitHubRunnerConfig, CreateStartRunnerConfig } from '../../../../core'; import { loadMicrovmProviderConfig } from './config'; -import { isRetryableMicrovmError, runMicrovmRunner, tagMicrovm, terminateMicrovm } from './microvms'; +import { isRetryableMicrovmError, runMicrovmRunner, terminateMicrovm } from './microvms'; import { createMicrovmRunHookPayload, createMicrovmRunners } from './runner-config'; +import { setMicrovmGithubRunnerId } from './runner-metadata'; vi.mock('./config', () => ({ loadMicrovmProviderConfig: vi.fn() })); vi.mock('./microvms', () => ({ isRetryableMicrovmError: vi.fn(), runMicrovmRunner: vi.fn(), - tagMicrovm: vi.fn(), terminateMicrovm: vi.fn(), })); +vi.mock('./runner-metadata', async (importOriginal) => ({ + ...(await importOriginal()), + setMicrovmGithubRunnerId: vi.fn(), +})); const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; +const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; const githubClient = {} as Octokit; const createStartRunnerConfig = vi.fn(); @@ -42,9 +47,10 @@ beforeEach(() => { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', maximumDurationInSeconds: 1200, + metadataSsmPath, }); vi.mocked(runMicrovmRunner).mockResolvedValue('mvm-1'); - vi.mocked(tagMicrovm).mockResolvedValue(); + vi.mocked(setMicrovmGithubRunnerId).mockResolvedValue(); vi.mocked(terminateMicrovm).mockResolvedValue(); vi.mocked(isRetryableMicrovmError).mockReturnValue(false); createStartRunnerConfig.mockResolvedValue([]); @@ -83,6 +89,20 @@ describe('createMicrovmRunners', () => { ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); }); + it('rejects a metadata path that overlaps the JIT configuration path', async () => { + vi.mocked(loadMicrovmProviderConfig).mockReturnValue({ + imageIdentifier: imageArn, + executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', + maximumDurationInSeconds: 1200, + metadataSsmPath: '/github-action-runners/unit-test/token/metadata', + }); + + await expect( + createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), + ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); + expect(runMicrovmRunner).not.toHaveBeenCalled(); + }); + it('classifies invalid provider configuration as non-retryable', async () => { vi.mocked(loadMicrovmProviderConfig).mockImplementation(() => { throw new Error('missing image'); @@ -115,9 +135,7 @@ describe('createMicrovmRunners', () => { expect(createStartRunnerConfig).toHaveBeenCalledTimes(2); const options = createStartRunnerConfig.mock.calls[0][3]; expect(options?.getSsmParameterTags?.('mvm-1')).toEqual([{ Key: 'MicrovmId', Value: 'mvm-1' }]); - expect(tagMicrovm).toHaveBeenNthCalledWith(1, imageArn, 'mvm-1', { - 'ghr:github_runner_id': 'github-mvm-1', - }); + expect(setMicrovmGithubRunnerId).toHaveBeenNthCalledWith(1, metadataSsmPath, 'mvm-1', 'github-mvm-1'); }); it('applies dynamic labels to the RunMicrovm configuration and metadata tags', async () => { @@ -143,6 +161,7 @@ describe('createMicrovmRunners', () => { imageVersion: '3.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', maximumDurationInSeconds: 7200, + metadataSsmPath, }, environment: 'unit-test', runHookPayload: createMicrovmRunHookPayload('/github-action-runners/unit-test/token'), @@ -150,9 +169,7 @@ describe('createMicrovmRunners', () => { runnerType: 'Org', source: 'scale-up-lambda', }); - expect(tagMicrovm).toHaveBeenCalledWith(overrideImageArn, 'mvm-1', { - 'ghr:github_runner_id': 'github-mvm-1', - }); + expect(setMicrovmGithubRunnerId).toHaveBeenCalledWith(metadataSsmPath, 'mvm-1', 'github-mvm-1'); }); it('retries a JIT setup failure even when runner cleanup fails', async () => { @@ -163,7 +180,7 @@ describe('createMicrovmRunners', () => { createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), ).resolves.toEqual({ instances: [], retryableErrorCount: 1, nonRetryableErrorCount: 0 }); - expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1'); + expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', metadataSsmPath); }); it.each([ @@ -186,6 +203,6 @@ describe('createMicrovmRunners', () => { createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); - expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1'); + expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', metadataSsmPath); }); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts index ca3497dd0e..5393a49d85 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -9,7 +9,8 @@ import type { } from '../../../../core'; import type { MicrovmDynamicLabelOverrides } from '../dynamic-labels'; import { loadMicrovmProviderConfig } from './config'; -import { isRetryableMicrovmError, runMicrovmRunner, tagMicrovm, terminateMicrovm } from './microvms'; +import { isRetryableMicrovmError, runMicrovmRunner, terminateMicrovm } from './microvms'; +import { assertSeparatedMicrovmMetadataPath, setMicrovmGithubRunnerId } from './runner-metadata'; const logger = createChildLogger('microvm-runner-config'); @@ -46,6 +47,7 @@ export async function createMicrovmRunners( let config; try { config = { ...loadMicrovmProviderConfig(), ...overrides }; + assertSeparatedMicrovmMetadataPath(config.metadataSsmPath, githubRunnerConfig.ssmTokenPath); } catch (error) { logger.error('Invalid Lambda MicroVM provider configuration', { error }); return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; @@ -73,14 +75,12 @@ export async function createMicrovmRunners( const failedRunnerIds = await createStartRunnerConfig(githubRunnerConfig, [microvmId], githubInstallationClient, { getSsmParameterTags: (runnerId) => [{ Key: 'MicrovmId', Value: runnerId }], onJitConfigCreated: async (runnerId, metadata) => { - await tagMicrovm(config.imageIdentifier, runnerId, { - 'ghr:github_runner_id': metadata.githubRunnerId, - }); + await setMicrovmGithubRunnerId(config.metadataSsmPath, runnerId, metadata.githubRunnerId); }, }); if (failedRunnerIds.includes(microvmId)) { - await terminateMicrovm(microvmId).catch((terminationError) => { + await terminateMicrovm(microvmId, config.metadataSsmPath).catch((terminationError) => { logger.error(`Failed to terminate MicroVM runner '${microvmId}' after JIT configuration failed`, { error: terminationError, }); @@ -91,7 +91,7 @@ export async function createMicrovmRunners( } } catch (error) { if (microvmId) { - await terminateMicrovm(microvmId).catch((terminationError) => { + await terminateMicrovm(microvmId, config.metadataSsmPath).catch((terminationError) => { logger.error(`Failed to terminate MicroVM runner '${microvmId}' after setup failed`, { error: terminationError, }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts new file mode 100644 index 0000000000..d9db41e4a4 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts @@ -0,0 +1,253 @@ +import { deleteParameter, getParametersByPath, putParameter } from '@aws-github-runner/aws-ssm-util'; +import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + assertSeparatedMicrovmMetadataPath, + createMicrovmRunnerMetadata, + deleteMicrovmRunnerMetadata, + listMicrovmRunnerMetadata, + markMicrovmCleanupPending, + microvmMetadataParameterName, + setMicrovmGithubRunnerId, + setMicrovmOrphan, + type MicrovmRunnerMetadata, +} from './runner-metadata'; + +vi.mock('@aws-github-runner/aws-ssm-util', () => ({ + deleteParameter: vi.fn(), + getParametersByPath: vi.fn(), + putParameter: vi.fn(), +})); + +const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; + +function metadata(overrides: Partial = {}): MicrovmRunnerMetadata { + return { + version: 1, + microvmId: 'mvm-1', + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'scale-up-lambda', + imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', + imageVersion: '3.0', + createdAt: '2026-08-19T10:00:00.000Z', + expiresAt: '2026-08-19T11:00:00.000Z', + ...overrides, + }; +} + +function states(entries: [string, MicrovmState][]): Map { + return new Map(entries); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + vi.mocked(deleteParameter).mockResolvedValue(); + vi.mocked(getParametersByPath).mockResolvedValue(new Map()); + vi.mocked(putParameter).mockResolvedValue(); +}); + +describe('MicroVM metadata paths', () => { + it('uses one base parameter per validated MicroVM ID', () => { + expect(microvmMetadataParameterName(`${metadataSsmPath}/`, 'microvm-123')).toBe(`${metadataSsmPath}/microvm-123`); + expect(() => microvmMetadataParameterName(metadataSsmPath, '../other')).toThrow('Invalid MicroVM identifier'); + }); + + it('requires metadata to use a prefix separate from JIT configuration', () => { + expect(() => + assertSeparatedMicrovmMetadataPath(metadataSsmPath, '/github-action-runners/unit-test/token'), + ).not.toThrow(); + expect(() => assertSeparatedMicrovmMetadataPath('/runner/token/metadata', '/runner/token')).toThrow( + 'must be separate', + ); + expect(() => assertSeparatedMicrovmMetadataPath('/runner', '/runner/token')).toThrow('must be separate'); + }); +}); + +describe('MicroVM metadata lifecycle', () => { + it('creates non-secret, expiring ownership metadata without overwrite', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-19T10:00:00.000Z')); + + await createMicrovmRunnerMetadata(metadataSsmPath, { + microvmId: 'mvm-1', + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'scale-up-lambda', + imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', + imageVersion: '3.0', + maximumDurationInSeconds: 1200, + }); + + expect(putParameter).toHaveBeenCalledWith( + `${metadataSsmPath}/mvm-1`, + JSON.stringify(metadata({ expiresAt: '2026-08-19T10:25:00.000Z' })), + false, + ); + }); + + it('loads active metadata with independent state and cleans expired inactive records', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); + const active = metadata({ expiresAt: '2026-08-19T12:30:00.000Z' }); + const expiredInactive = metadata({ microvmId: 'mvm-old', expiresAt: '2026-08-19T11:00:00.000Z' }); + const unexpiredInactive = metadata({ microvmId: 'mvm-new', expiresAt: '2026-08-19T12:30:00.000Z' }); + vi.mocked(getParametersByPath).mockResolvedValue( + new Map([ + [`${metadataSsmPath}/mvm-1`, JSON.stringify(active)], + [`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42'], + [`${metadataSsmPath}/mvm-1.orphan`, 'true'], + [`${metadataSsmPath}/mvm-old`, JSON.stringify(expiredInactive)], + [`${metadataSsmPath}/mvm-new`, JSON.stringify(unexpiredInactive)], + [`${metadataSsmPath}/mvm-invalid`, '{not-json'], + ]), + ); + + await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).resolves.toEqual({ + cleanupMicrovmIds: [], + metadataById: new Map([['mvm-1', { ...active, githubRunnerId: 'github-42', orphan: true }]]), + }); + expect(getParametersByPath).toHaveBeenCalledWith(metadataSsmPath); + expect(deleteParameter).toHaveBeenCalledTimes(4); + expect(deleteParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-old`); + expect(deleteParameter).not.toHaveBeenCalledWith(`${metadataSsmPath}/mvm-new`); + }); + + it('fails closed for invalid metadata or state belonging to an active MicroVM', async () => { + vi.mocked(getParametersByPath).mockResolvedValue(new Map([[`${metadataSsmPath}/mvm-1`, '{not-json']])); + await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( + 'invalid ownership metadata', + ); + + vi.mocked(getParametersByPath).mockResolvedValue( + new Map([ + [`${metadataSsmPath}/mvm-1`, JSON.stringify(metadata())], + [`${metadataSsmPath}/mvm-1.orphan`, 'invalid'], + ]), + ); + await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( + 'invalid orphan state', + ); + }); + + it('propagates metadata path lookup errors so inventory fails closed', async () => { + vi.mocked(getParametersByPath).mockRejectedValue(new Error('AccessDenied')); + + await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( + 'AccessDenied', + ); + }); + + it('updates GitHub and orphan state without a shared read-modify-write record', async () => { + await setMicrovmGithubRunnerId(metadataSsmPath, 'mvm-1', 'github-42'); + expect(putParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42', false, { + overwrite: true, + }); + + await setMicrovmOrphan(metadataSsmPath, 'mvm-1', true); + expect(putParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-1.orphan`, 'true', false, { + overwrite: true, + }); + }); + + it('marks cleanup independently and deletes state before ownership metadata', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); + + await markMicrovmCleanupPending(metadataSsmPath, 'mvm-1'); + expect(putParameter).toHaveBeenCalledWith( + `${metadataSsmPath}/mvm-1.cleanup-requested-at`, + '2026-08-19T12:00:00.000Z', + false, + { overwrite: true }, + ); + + await deleteMicrovmRunnerMetadata(metadataSsmPath, 'mvm-1'); + expect(vi.mocked(deleteParameter).mock.calls.map(([name]) => name)).toEqual([ + `${metadataSsmPath}/mvm-1.github-runner-id`, + `${metadataSsmPath}/mvm-1.orphan`, + `${metadataSsmPath}/mvm-1.cleanup-requested-at`, + `${metadataSsmPath}/mvm-1`, + ]); + }); + + it('returns tracked and state-only active cleanup requests for termination retry', async () => { + vi.mocked(getParametersByPath).mockResolvedValue( + new Map([ + [`${metadataSsmPath}/mvm-1`, JSON.stringify(metadata())], + [`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42'], + [`${metadataSsmPath}/mvm-1.cleanup-requested-at`, '2026-08-19T10:15:00.000Z'], + [`${metadataSsmPath}/mvm-untracked.cleanup-requested-at`, '2026-08-19T10:15:00.000Z'], + [`${metadataSsmPath}/mvm-terminating.cleanup-requested-at`, '2026-08-19T10:15:00.000Z'], + ]), + ); + + await expect( + listMicrovmRunnerMetadata( + metadataSsmPath, + states([ + ['mvm-1', 'RUNNING'], + ['mvm-untracked', 'PENDING'], + ['mvm-terminating', 'TERMINATING'], + ]), + ), + ).resolves.toEqual({ + cleanupMicrovmIds: ['mvm-1', 'mvm-untracked'], + metadataById: new Map(), + }); + expect(deleteParameter).not.toHaveBeenCalled(); + }); + + it('does not starve cleanup requests when more than one reconciliation batch is pending', async () => { + const cleanupIds = Array.from({ length: 11 }, (_, index) => `mvm-cleanup-${index}`); + vi.mocked(getParametersByPath).mockResolvedValue( + new Map( + cleanupIds.map((microvmId) => [ + `${metadataSsmPath}/${microvmId}.cleanup-requested-at`, + '2026-08-19T10:15:00.000Z', + ]), + ), + ); + + await expect( + listMicrovmRunnerMetadata( + metadataSsmPath, + states(cleanupIds.map((microvmId): [string, MicrovmState] => [microvmId, 'RUNNING'])), + ), + ).resolves.toEqual({ cleanupMicrovmIds: cleanupIds, metadataById: new Map() }); + }); + + it('cleans terminal state-only records and aged markers after inventory no longer sees the MicroVM', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); + vi.mocked(getParametersByPath).mockResolvedValue( + new Map([ + [`${metadataSsmPath}/mvm-terminal.github-runner-id`, 'github-42'], + [`${metadataSsmPath}/mvm-missing.cleanup-requested-at`, '2026-08-19T11:54:59.000Z'], + [`${metadataSsmPath}/mvm-recent.cleanup-requested-at`, '2026-08-19T11:59:00.000Z'], + ]), + ); + + await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-terminal', 'TERMINATED']]))).resolves.toEqual( + { cleanupMicrovmIds: [], metadataById: new Map() }, + ); + expect(deleteParameter).toHaveBeenCalledTimes(8); + expect(deleteParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-terminal`); + expect(deleteParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-missing`); + expect(deleteParameter).not.toHaveBeenCalledWith(`${metadataSsmPath}/mvm-recent`); + }); + + it('fails closed for active state metadata without ownership or a cleanup request', async () => { + vi.mocked(getParametersByPath).mockResolvedValue( + new Map([[`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42']]), + ); + + await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( + 'state metadata but no ownership metadata', + ); + }); +}); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts new file mode 100644 index 0000000000..3974334e07 --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -0,0 +1,348 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import { deleteParameter, getParametersByPath, putParameter } from '@aws-github-runner/aws-ssm-util'; +import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; + +import type { LambdaRunnerSource, RunnerType } from '../../../../core'; + +const logger = createChildLogger('microvm-runner-metadata'); + +const METADATA_VERSION = 1; +const EXPIRATION_GRACE_IN_SECONDS = 300; +const MAX_RECONCILED_RUNNERS = 10; +const MICROVM_ID_PATTERN = /^[A-Za-z0-9_-]+$/; +const GITHUB_RUNNER_ID_SUFFIX = '.github-runner-id'; +const ORPHAN_SUFFIX = '.orphan'; +const CLEANUP_REQUESTED_AT_SUFFIX = '.cleanup-requested-at'; +const ACTIVE_STATES = new Set(['PENDING', 'RUNNING', 'SUSPENDING', 'SUSPENDED']); + +export interface MicrovmRunnerMetadata { + bypassRemoval?: boolean; + createdAt: string; + environment: string; + expiresAt: string; + githubRunnerId?: string; + imageArn: string; + imageVersion?: string; + microvmId: string; + orphan?: boolean; + runnerOwner: string; + runnerType: RunnerType; + source: LambdaRunnerSource; + version: 1; +} + +export interface MicrovmRunnerMetadataInventory { + cleanupMicrovmIds: string[]; + metadataById: Map; +} + +export interface CreateMicrovmRunnerMetadataInput { + environment: string; + imageArn: string; + imageVersion?: string; + maximumDurationInSeconds: number; + microvmId: string; + runnerOwner: string; + runnerType: RunnerType; + source: LambdaRunnerSource; +} + +function normalizedPath(path: string): string { + return path.trim().replace(/\/+$/, ''); +} + +export function microvmMetadataParameterName(metadataSsmPath: string, microvmId: string): string { + if (!MICROVM_ID_PATTERN.test(microvmId)) { + throw new Error(`Invalid MicroVM identifier '${microvmId}'`); + } + return `${normalizedPath(metadataSsmPath)}/${microvmId}`; +} + +function stateParameterName(metadataSsmPath: string, microvmId: string, suffix: string): string { + return `${microvmMetadataParameterName(metadataSsmPath, microvmId)}${suffix}`; +} + +function metadataParameterNames(metadataSsmPath: string, microvmId: string): string[] { + const baseName = microvmMetadataParameterName(metadataSsmPath, microvmId); + return [ + `${baseName}${GITHUB_RUNNER_ID_SUFFIX}`, + `${baseName}${ORPHAN_SUFFIX}`, + `${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`, + baseName, + ]; +} + +export function assertSeparatedMicrovmMetadataPath(metadataSsmPath: string, runnerConfigSsmPath: string): void { + const metadataPath = normalizedPath(metadataSsmPath); + const runnerConfigPath = normalizedPath(runnerConfigSsmPath); + if ( + metadataPath === runnerConfigPath || + metadataPath.startsWith(`${runnerConfigPath}/`) || + runnerConfigPath.startsWith(`${metadataPath}/`) + ) { + throw new Error('MICROVM_METADATA_SSM_PATH must be separate from the runner JIT configuration path'); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): value is string | undefined { + return value === undefined || (typeof value === 'string' && value.length > 0); +} + +function optionalBoolean(value: unknown): value is boolean | undefined { + return value === undefined || typeof value === 'boolean'; +} + +function parseMetadata(value: string, expectedMicrovmId: string): MicrovmRunnerMetadata | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return undefined; + } + + if (!isRecord(parsed)) return undefined; + + const createdAt = typeof parsed.createdAt === 'string' ? Date.parse(parsed.createdAt) : Number.NaN; + const expiresAt = typeof parsed.expiresAt === 'string' ? Date.parse(parsed.expiresAt) : Number.NaN; + if ( + parsed.version !== METADATA_VERSION || + parsed.microvmId !== expectedMicrovmId || + typeof parsed.environment !== 'string' || + parsed.environment.length === 0 || + typeof parsed.runnerOwner !== 'string' || + parsed.runnerOwner.length === 0 || + (parsed.runnerType !== 'Org' && parsed.runnerType !== 'Repo') || + (parsed.source !== 'scale-up-lambda' && parsed.source !== 'pool-lambda') || + typeof parsed.imageArn !== 'string' || + parsed.imageArn.length === 0 || + !optionalString(parsed.imageVersion) || + !optionalBoolean(parsed.bypassRemoval) || + !Number.isFinite(createdAt) || + !Number.isFinite(expiresAt) || + expiresAt <= createdAt + ) { + return undefined; + } + + return { + version: METADATA_VERSION, + microvmId: expectedMicrovmId, + environment: parsed.environment, + runnerOwner: parsed.runnerOwner, + runnerType: parsed.runnerType, + source: parsed.source, + imageArn: parsed.imageArn, + imageVersion: parsed.imageVersion, + bypassRemoval: parsed.bypassRemoval, + createdAt: parsed.createdAt as string, + expiresAt: parsed.expiresAt as string, + }; +} + +export async function createMicrovmRunnerMetadata( + metadataSsmPath: string, + input: CreateMicrovmRunnerMetadataInput, +): Promise { + const createdAt = new Date(); + const metadata: MicrovmRunnerMetadata = { + version: METADATA_VERSION, + microvmId: input.microvmId, + environment: input.environment, + runnerOwner: input.runnerOwner, + runnerType: input.runnerType, + source: input.source, + imageArn: input.imageArn, + imageVersion: input.imageVersion, + createdAt: createdAt.toISOString(), + expiresAt: new Date( + createdAt.getTime() + (input.maximumDurationInSeconds + EXPIRATION_GRACE_IN_SECONDS) * 1000, + ).toISOString(), + }; + + await putParameter(microvmMetadataParameterName(metadataSsmPath, input.microvmId), JSON.stringify(metadata), false); +} + +function invalidStateReason(parameters: Map, baseName: string): string | undefined { + const orphan = parameters.get(`${baseName}${ORPHAN_SUFFIX}`); + if (orphan !== undefined && orphan !== 'true' && orphan !== 'false') return 'invalid orphan state'; + + const cleanupRequestedAt = parameters.get(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); + if (cleanupRequestedAt !== undefined && !Number.isFinite(Date.parse(cleanupRequestedAt))) { + return 'invalid cleanup request timestamp'; + } + return undefined; +} + +function shouldDeleteMetadata( + metadata: MicrovmRunnerMetadata, + state: MicrovmState | undefined, + cleanupRequestedAt: string | undefined, + now: number, +): boolean { + if (state === 'TERMINATED') return true; + if (state !== undefined) return false; + + const cleanupGraceElapsed = + cleanupRequestedAt !== undefined && Date.parse(cleanupRequestedAt) + EXPIRATION_GRACE_IN_SECONDS * 1000 <= now; + return cleanupGraceElapsed || Date.parse(metadata.expiresAt) <= now; +} + +export async function listMicrovmRunnerMetadata( + metadataSsmPath: string, + microvmStates: ReadonlyMap, +): Promise { + const metadataById = new Map(); + const cleanupMicrovmIds = new Set(); + const parameters = await getParametersByPath(normalizedPath(metadataSsmPath)); + const parameterPrefix = `${normalizedPath(metadataSsmPath)}/`; + const now = Date.now(); + const metadataBaseIds = new Set(); + const stateParameterIds = new Set(); + const runnersToDelete = new Set(); + + for (const parameterName of parameters.keys()) { + if (!parameterName.startsWith(parameterPrefix)) continue; + for (const suffix of [GITHUB_RUNNER_ID_SUFFIX, ORPHAN_SUFFIX, CLEANUP_REQUESTED_AT_SUFFIX]) { + if (!parameterName.endsWith(suffix)) continue; + const microvmId = parameterName.slice(parameterPrefix.length, -suffix.length); + if (MICROVM_ID_PATTERN.test(microvmId)) stateParameterIds.add(microvmId); + break; + } + } + + for (const [parameterName, value] of parameters) { + if (!parameterName.startsWith(parameterPrefix)) continue; + const microvmId = parameterName.slice(parameterPrefix.length); + if (!MICROVM_ID_PATTERN.test(microvmId)) continue; + metadataBaseIds.add(microvmId); + + const state = microvmStates.get(microvmId); + const metadata = parseMetadata(value, microvmId); + if (!metadata) { + if (state !== undefined && ACTIVE_STATES.has(state)) { + throw new Error(`Active MicroVM runner '${microvmId}' has invalid ownership metadata`); + } + if (state === 'TERMINATED') runnersToDelete.add(microvmId); + else logger.warn(`Ignoring invalid MicroVM runner metadata for '${microvmId}'`); + continue; + } + + const baseName = microvmMetadataParameterName(metadataSsmPath, microvmId); + const stateError = invalidStateReason(parameters, baseName); + if (stateError) { + if (state !== undefined && ACTIVE_STATES.has(state)) { + throw new Error(`Active MicroVM runner '${microvmId}' has ${stateError}`); + } + if (state === 'TERMINATED' || (state === undefined && Date.parse(metadata.expiresAt) <= now)) { + runnersToDelete.add(microvmId); + } + logger.warn(`Ignoring MicroVM runner metadata for '${microvmId}' with ${stateError}`); + continue; + } + + const cleanupRequestedAt = parameters.get(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); + if (shouldDeleteMetadata(metadata, state, cleanupRequestedAt, now)) { + runnersToDelete.add(microvmId); + continue; + } + + if (state === undefined || !ACTIVE_STATES.has(state)) continue; + + if (cleanupRequestedAt !== undefined) { + cleanupMicrovmIds.add(microvmId); + continue; + } + + metadataById.set(microvmId, { + ...metadata, + githubRunnerId: parameters.get(`${baseName}${GITHUB_RUNNER_ID_SUFFIX}`), + orphan: parameters.get(`${baseName}${ORPHAN_SUFFIX}`) === 'true', + }); + } + + for (const microvmId of stateParameterIds) { + if (metadataBaseIds.has(microvmId)) continue; + + const baseName = microvmMetadataParameterName(metadataSsmPath, microvmId); + const state = microvmStates.get(microvmId); + const cleanupRequestedAt = parameters.get(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); + const stateError = invalidStateReason(parameters, baseName); + + if (stateError && state !== undefined && ACTIVE_STATES.has(state)) { + throw new Error(`Active MicroVM runner '${microvmId}' has ${stateError}`); + } + if (state !== undefined && ACTIVE_STATES.has(state)) { + if (cleanupRequestedAt === undefined) { + throw new Error(`Active MicroVM runner '${microvmId}' has state metadata but no ownership metadata`); + } + cleanupMicrovmIds.add(microvmId); + continue; + } + if (state === 'TERMINATED') { + runnersToDelete.add(microvmId); + continue; + } + if (state === undefined) { + const cleanupGraceElapsed = + cleanupRequestedAt !== undefined && + Number.isFinite(Date.parse(cleanupRequestedAt)) && + Date.parse(cleanupRequestedAt) + EXPIRATION_GRACE_IN_SECONDS * 1000 <= now; + if (cleanupRequestedAt === undefined || stateError !== undefined || cleanupGraceElapsed) { + runnersToDelete.add(microvmId); + } + } + } + + for (const microvmId of [...runnersToDelete].slice(0, MAX_RECONCILED_RUNNERS)) { + try { + await deleteMicrovmRunnerMetadata(metadataSsmPath, microvmId); + } catch (error) { + logger.warn(`Failed to delete reconciled MicroVM runner metadata '${microvmId}'`, { error }); + } + } + + return { + cleanupMicrovmIds: [...cleanupMicrovmIds], + metadataById, + }; +} + +export async function setMicrovmGithubRunnerId( + metadataSsmPath: string, + microvmId: string, + githubRunnerId: string, +): Promise { + if (!githubRunnerId) throw new Error('GitHub runner ID must not be empty'); + await putParameter(stateParameterName(metadataSsmPath, microvmId, GITHUB_RUNNER_ID_SUFFIX), githubRunnerId, false, { + overwrite: true, + }); +} + +export async function setMicrovmOrphan(metadataSsmPath: string, microvmId: string, orphan: boolean): Promise { + await putParameter(stateParameterName(metadataSsmPath, microvmId, ORPHAN_SUFFIX), String(orphan), false, { + overwrite: true, + }); +} + +export async function markMicrovmCleanupPending(metadataSsmPath: string, microvmId: string): Promise { + await putParameter( + stateParameterName(metadataSsmPath, microvmId, CLEANUP_REQUESTED_AT_SUFFIX), + new Date().toISOString(), + false, + { overwrite: true }, + ); +} + +export async function deleteMicrovmRunnerMetadata(metadataSsmPath: string, microvmId: string): Promise { + for (const parameterName of metadataParameterNames(metadataSsmPath, microvmId)) { + try { + await deleteParameter(parameterName); + } catch (error) { + if (!(error instanceof Error && error.name === 'ParameterNotFound')) throw error; + } + } +} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts index 613364e7d2..fce2d06137 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts @@ -1,24 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { loadMicrovmProviderConfig } from './config'; -import { listMicrovmRunners, microvmBootTimeExceeded, tagMicrovm, terminateMicrovm, untagMicrovm } from './microvms'; +import { listMicrovmRunners, microvmBootTimeExceeded, terminateMicrovm } from './microvms'; import { createMicrovmScaleDownProvider } from './scale-down'; +import { setMicrovmOrphan } from './runner-metadata'; vi.mock('./config', () => ({ loadMicrovmProviderConfig: vi.fn() })); vi.mock('./microvms', () => ({ listMicrovmRunners: vi.fn(), microvmBootTimeExceeded: vi.fn(), - tagMicrovm: vi.fn(), terminateMicrovm: vi.fn(), - untagMicrovm: vi.fn(), })); +vi.mock('./runner-metadata', () => ({ setMicrovmOrphan: vi.fn() })); const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; -const overrideImageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-large'; +const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; const providerConfig = { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', maximumDurationInSeconds: 1200, + metadataSsmPath, }; beforeEach(() => { @@ -26,8 +27,7 @@ beforeEach(() => { vi.mocked(loadMicrovmProviderConfig).mockReturnValue(providerConfig); vi.mocked(listMicrovmRunners).mockResolvedValue([]); vi.mocked(microvmBootTimeExceeded).mockReturnValue(false); - vi.mocked(tagMicrovm).mockResolvedValue(); - vi.mocked(untagMicrovm).mockResolvedValue(); + vi.mocked(setMicrovmOrphan).mockResolvedValue(); vi.mocked(terminateMicrovm).mockResolvedValue(); }); @@ -38,30 +38,34 @@ describe('createMicrovmScaleDownProvider', () => { await provider.list('unit-test'); await provider.list('unit-test', true); - expect(listMicrovmRunners).toHaveBeenNthCalledWith(1, { - environment: 'unit-test', - orphan: undefined, - }); - expect(listMicrovmRunners).toHaveBeenNthCalledWith(2, { - environment: 'unit-test', - orphan: true, - }); + expect(listMicrovmRunners).toHaveBeenNthCalledWith( + 1, + { + environment: 'unit-test', + orphan: undefined, + }, + metadataSsmPath, + ); + expect(listMicrovmRunners).toHaveBeenNthCalledWith( + 2, + { + environment: 'unit-test', + orphan: true, + }, + metadataSsmPath, + ); }); - it('uses the listed image ARN when marking, unmarking, and terminating runners', async () => { - vi.mocked(listMicrovmRunners).mockResolvedValue([ - { id: 'mvm-1', imageArn: overrideImageArn, owner: 'Codertocat', type: 'Org', state: 'RUNNING' }, - ]); + it('uses durable metadata when marking, unmarking, and terminating runners', async () => { const provider = createMicrovmScaleDownProvider(); - await provider.list('unit-test'); await provider.markOrphan('mvm-1'); await provider.unmarkOrphan('mvm-1'); await provider.terminate('mvm-1'); - expect(tagMicrovm).toHaveBeenCalledWith(overrideImageArn, 'mvm-1', { 'ghr:orphan': 'true' }); - expect(untagMicrovm).toHaveBeenCalledWith(overrideImageArn, 'mvm-1', ['ghr:orphan']); - expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1'); + expect(setMicrovmOrphan).toHaveBeenNthCalledWith(1, metadataSsmPath, 'mvm-1', true); + expect(setMicrovmOrphan).toHaveBeenNthCalledWith(2, metadataSsmPath, 'mvm-1', false); + expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', metadataSsmPath); }); it('uses the MicroVM boot-time policy', () => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts index 9ea9dc474a..9cda68cf53 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts @@ -1,28 +1,21 @@ import type { ScaleDownComputeProvider } from '../../../../core'; import { loadMicrovmProviderConfig } from './config'; import type { MicrovmRunnerInfo } from './microvms'; -import { listMicrovmRunners, microvmBootTimeExceeded, tagMicrovm, terminateMicrovm, untagMicrovm } from './microvms'; +import { listMicrovmRunners, microvmBootTimeExceeded, terminateMicrovm } from './microvms'; +import { setMicrovmOrphan } from './runner-metadata'; export function createMicrovmScaleDownProvider(): Omit { - const imageArnByRunnerId = new Map(); + const metadataSsmPath = () => loadMicrovmProviderConfig().metadataSsmPath; async function list(environment: string, orphan?: boolean): Promise { - const runners = await listMicrovmRunners({ environment, orphan }); - for (const runner of runners) { - if (runner.imageArn) imageArnByRunnerId.set(runner.id, runner.imageArn); - } - return runners; - } - - function imageArnForRunner(id: string): string { - return imageArnByRunnerId.get(id) ?? loadMicrovmProviderConfig().imageIdentifier; + return await listMicrovmRunners({ environment, orphan }, metadataSsmPath()); } return { list, bootTimeExceeded: microvmBootTimeExceeded, - markOrphan: async (id) => await tagMicrovm(imageArnForRunner(id), id, { 'ghr:orphan': 'true' }), - unmarkOrphan: async (id) => await untagMicrovm(imageArnForRunner(id), id, ['ghr:orphan']), - terminate: terminateMicrovm, + markOrphan: async (id) => await setMicrovmOrphan(metadataSsmPath(), id, true), + unmarkOrphan: async (id) => await setMicrovmOrphan(metadataSsmPath(), id, false), + terminate: async (id) => await terminateMicrovm(id, metadataSsmPath()), }; } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts index 91c1931f83..06668b935f 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts @@ -10,6 +10,7 @@ declare global { MICROVM_INGRESS_NETWORK_CONNECTORS: string | undefined; MICROVM_LOG_GROUP: string | undefined; MICROVM_MAXIMUM_DURATION_IN_SECONDS: string | undefined; + MICROVM_METADATA_SSM_PATH: string; } } } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels-policy.ts b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels-policy.ts deleted file mode 100644 index 9785383727..0000000000 --- a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels-policy.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { AwsDynamicLabelsPolicy } from '../../../../contracts'; - -function globToRegExp(glob: string): RegExp { - const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); - const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); - return new RegExp(`^${pattern}$`); -} - -function matchesAny(value: string, patterns: string[] | undefined): boolean { - if (!patterns || patterns.length === 0) return false; - return patterns.some((pattern) => globToRegExp(pattern).test(value)); -} - -function evaluateLabel(label: string, policy: AwsDynamicLabelsPolicy, labelPrefix: string): string | null { - const stripped = label.slice(labelPrefix.length); - const colonIndex = stripped.indexOf(':'); - const key = colonIndex === -1 ? stripped : stripped.slice(0, colonIndex); - const value = colonIndex === -1 ? undefined : stripped.slice(colonIndex + 1); - - if (policy.blocked_keys?.includes(key)) { - return `key '${key}' is in blocked_keys`; - } - - const rule = policy.restricted_keys?.[key]; - if (!rule || value === undefined) return null; - - if (rule.allowed && rule.allowed.length > 0 && !matchesAny(value, rule.allowed)) { - return `value '${value}' not in allowed list`; - } - if (rule.denied && matchesAny(value, rule.denied)) { - return `value '${value}' in denied list`; - } - if (rule.max !== undefined && rule.max !== null) { - const valueNumber = Number(value); - const maximum = Number(rule.max); - if (!Number.isFinite(valueNumber) || !Number.isFinite(maximum)) { - return `max set but value '${value}' or max '${rule.max}' is not numeric`; - } - if (valueNumber > maximum) { - return `value '${value}' exceeds max '${rule.max}'`; - } - } - - return null; -} - -export function violationsAgainstAwsDynamicLabelsPolicy( - labels: string[], - policy: AwsDynamicLabelsPolicy | null | undefined, - labelPrefix: string, -): { label: string; reason: string }[] { - if (!policy) return []; - - const violations: { label: string; reason: string }[] = []; - for (const label of labels) { - if (!label.startsWith(labelPrefix)) continue; - const reason = evaluateLabel(label, policy, labelPrefix); - if (reason) violations.push({ label, reason }); - } - return violations; -} diff --git a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts index 6b157309af..b21ad792f3 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts @@ -9,6 +9,13 @@ const egressConnectorArn = 'arn:aws:lambda:eu-west-1:123456789012:network-connec describe('microvmDynamicLabelProvider', () => { it('accepts supported MicroVM overrides', () => { const queue = microvmQueue(); + queue.matcherConfig.awsDynamicLabelsPolicy = { + restricted_keys: { + 'egress-network-connectors': { allowed: [egressConnectorArn] }, + 'image-arn': { allowed: [imageArn] }, + 'image-version': { allowed: ['3.0'] }, + }, + }; const dynamicLabels = [ `ghr-microvm-egress-network-connectors:${egressConnectorArn}`, `ghr-microvm-image-arn:${imageArn}`, @@ -16,21 +23,40 @@ describe('microvmDynamicLabelProvider', () => { 'ghr-microvm-maximum-duration-in-seconds:7200', ]; - expect(selectQueue(queue, dynamicLabels)).toEqual({ - queue, - labels: ['self-hosted', 'linux', ...dynamicLabels], - }); + expect(getViolations(queue, dynamicLabels)).toEqual([]); }); - it('rejects dynamic labels when the queue disables them', () => { - const queue = microvmQueue(); - queue.matcherConfig.enableDynamicLabels = false; - - expect(selectQueue(queue, ['ghr-microvm-image-version:3.0'])).toBeUndefined(); + it('requires explicit allowlists for image code and network-boundary overrides', () => { + expect( + getViolations(microvmQueue(), [ + `ghr-microvm-egress-network-connectors:${egressConnectorArn}`, + `ghr-microvm-image-arn:${imageArn}`, + 'ghr-microvm-image-version:3.0', + 'ghr-microvm-maximum-duration-in-seconds:3600', + ]), + ).toEqual([ + { + label: `ghr-microvm-egress-network-connectors:${egressConnectorArn}`, + reason: "key 'egress-network-connectors' requires an explicit allowed list", + }, + { + label: `ghr-microvm-image-arn:${imageArn}`, + reason: "key 'image-arn' requires an explicit allowed list", + }, + { + label: 'ghr-microvm-image-version:3.0', + reason: "key 'image-version' requires an explicit allowed list", + }, + ]); }); - it('rejects unsupported MicroVM resource overrides', () => { - expect(selectQueue(microvmQueue(), ['ghr-microvm-memory:8192'])).toBeUndefined(); + it('preserves violations from the MicroVM label parser', () => { + expect(getViolations(microvmQueue(), ['ghr-microvm-memory:8192'])).toEqual([ + { + label: 'ghr-microvm-memory:8192', + reason: "key 'memory' is not a supported MicroVM override", + }, + ]); }); it('enforces the AWS dynamic-label policy', () => { @@ -39,7 +65,12 @@ describe('microvmDynamicLabelProvider', () => { restricted_keys: { 'maximum-duration-in-seconds': { max: 3600 } }, }; - expect(selectQueue(queue, ['ghr-microvm-maximum-duration-in-seconds:7200'])).toBeUndefined(); + expect(getViolations(queue, ['ghr-microvm-maximum-duration-in-seconds:7200'])).toEqual([ + { + label: 'ghr-microvm-maximum-duration-in-seconds:7200', + reason: "value '7200' exceeds max '3600'", + }, + ]); }); it('applies allowed patterns to the complete image ARN', () => { @@ -53,11 +84,13 @@ describe('microvmDynamicLabelProvider', () => { }; expect( - selectQueue(queue, ['ghr-microvm-image-arn:arn:aws:lambda:eu-west-1:123456789012:microvm-image:approved-large']), - ).toBeDefined(); + getViolations(queue, [ + 'ghr-microvm-image-arn:arn:aws:lambda:eu-west-1:123456789012:microvm-image:approved-large', + ]), + ).toEqual([]); expect( - selectQueue(queue, ['ghr-microvm-image-arn:arn:aws:lambda:eu-west-1:123456789012:microvm-image:unapproved']), - ).toBeUndefined(); + getViolations(queue, ['ghr-microvm-image-arn:arn:aws:lambda:eu-west-1:123456789012:microvm-image:unapproved']), + ).toHaveLength(1); }); it('applies the policy to each egress connector label', () => { @@ -71,23 +104,22 @@ describe('microvmDynamicLabelProvider', () => { }; expect( - selectQueue(queue, [ + getViolations(queue, [ 'ghr-microvm-egress-network-connectors:arn:aws:lambda:eu-west-1:123456789012:network-connector:approved-private', ]), - ).toBeDefined(); + ).toEqual([]); expect( - selectQueue(queue, [ + getViolations(queue, [ 'ghr-microvm-egress-network-connectors:arn:aws:lambda:eu-west-1:123456789012:network-connector:unapproved', ]), - ).toBeUndefined(); + ).toHaveLength(1); }); }); -function selectQueue(queue: RunnerMatcherConfig, sanitizedGhrLabels: string[]) { - return microvmDynamicLabelProvider.selectQueue({ +function getViolations(queue: RunnerMatcherConfig, labels: string[]) { + return microvmDynamicLabelProvider.getViolations({ queue, - nonGhrLabels: ['self-hosted', 'linux'], - sanitizedGhrLabels, + labels, }); } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.ts b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.ts index 36eb3e7670..e7c5485617 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.ts @@ -1,48 +1,32 @@ -import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; - -import type { DynamicLabelDispatchTarget, DynamicLabelProvider, RunnerMatcherConfig } from '../../../../contracts'; +import type { DynamicLabelProvider } from '../../../../contracts'; +import { violationsAgainstAwsDynamicLabelsPolicy } from '../../../dynamic-labels-policy'; import { MICROVM_DYNAMIC_LABEL_PREFIX, parseMicrovmDynamicLabels } from '../dynamic-labels'; -import { violationsAgainstAwsDynamicLabelsPolicy } from './dynamic-labels-policy'; - -const logger = createChildLogger('handler'); -export function selectMicrovmDynamicLabelQueue( - matches: RunnerMatcherConfig[], - nonGhrLabels: string[], - sanitizedGhrLabels: string[], -): DynamicLabelDispatchTarget | undefined { - for (const queue of matches) { - if (!queue.matcherConfig.enableDynamicLabels) { - logger.warn(`Queue ${queue.id} matches non-dynamic labels but does not allow dynamic labels; trying next match`); - continue; - } +const RESOURCE_BOUNDARY_KEYS = new Set(['egress-network-connectors', 'image-arn', 'image-version']); - const parsedLabels = parseMicrovmDynamicLabels(sanitizedGhrLabels); - const policyViolations = violationsAgainstAwsDynamicLabelsPolicy( - sanitizedGhrLabels, - queue.matcherConfig.awsDynamicLabelsPolicy, - MICROVM_DYNAMIC_LABEL_PREFIX, - ); - const violations = [...parsedLabels.violations, ...policyViolations]; +function resourceBoundaryViolations( + labels: string[], + policy: Parameters[1], +) { + return labels.flatMap((label) => { + if (!label.startsWith(MICROVM_DYNAMIC_LABEL_PREFIX)) return []; - if (violations.length === 0) { - return { - queue, - labels: [...nonGhrLabels, ...sanitizedGhrLabels], - }; - } + const key = label.slice(MICROVM_DYNAMIC_LABEL_PREFIX.length).split(':', 1)[0]; + if (!RESOURCE_BOUNDARY_KEYS.has(key) || policy?.blocked_keys?.includes(key)) return []; - for (const violation of violations) { - logger.warn( - `Queue ${queue.id}: dynamic label '${violation.label}' is not accepted (${violation.reason}); trying next match`, - ); - } - } - - return undefined; + const allowed = policy?.restricted_keys?.[key]?.allowed; + return allowed && allowed.length > 0 ? [] : [{ label, reason: `key '${key}' requires an explicit allowed list` }]; + }); } export const microvmDynamicLabelProvider: DynamicLabelProvider = { - selectQueue: ({ queue, nonGhrLabels, sanitizedGhrLabels }) => - selectMicrovmDynamicLabelQueue([queue], nonGhrLabels, sanitizedGhrLabels), + getViolations: ({ queue, labels }) => [ + ...parseMicrovmDynamicLabels(labels).violations, + ...resourceBoundaryViolations(labels, queue.matcherConfig.awsDynamicLabelsPolicy), + ...violationsAgainstAwsDynamicLabelsPolicy( + labels, + queue.matcherConfig.awsDynamicLabelsPolicy, + MICROVM_DYNAMIC_LABEL_PREFIX, + ), + ], }; diff --git a/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts b/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts index bdc6d2918c..efe6bcd070 100644 --- a/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts @@ -1,36 +1,27 @@ -import { describe, expect, it } from 'vitest'; - -import type { RunnerMatcherConfig } from '../../contracts'; +import { defineWebhookProviderContractTests } from '../../test/webhook-provider-contract'; import { provider } from './webhook'; -describe('MicroVM webhook provider contract', () => { - it('exposes MicroVM dynamic-label selection', () => { - const plugin = provider.createPlugin(); - const queue = microvmQueue(); - - expect(plugin.type).toBe('microvm'); - expect( - plugin.capabilities.dynamicLabels.selectQueue({ - queue, - nonGhrLabels: ['self-hosted', 'linux'], - sanitizedGhrLabels: ['ghr-microvm-image-version:3.0'], - }), - ).toEqual({ - queue, - labels: ['self-hosted', 'linux', 'ghr-microvm-image-version:3.0'], - }); - }); -}); - -function microvmQueue(): RunnerMatcherConfig { - return { - id: 'microvm', - arn: 'arn:aws:sqs:eu-west-1:123456789012:microvm', - computeProvider: 'microvm', - matcherConfig: { - labelMatchers: [['self-hosted', 'linux']], - exactMatch: true, - enableDynamicLabels: true, +defineWebhookProviderContractTests({ + provider, + acceptedDynamicLabels: ['ghr-microvm-maximum-duration-in-seconds:3600'], + rejectingPolicies: [ + { + name: 'blocked keys', + apply: (queue) => { + queue.matcherConfig.awsDynamicLabelsPolicy = { + blocked_keys: ['maximum-duration-in-seconds'], + }; + }, + }, + { + name: 'restricted keys', + apply: (queue) => { + queue.matcherConfig.awsDynamicLabelsPolicy = { + restricted_keys: { + 'maximum-duration-in-seconds': { max: 1800 }, + }, + }; + }, }, - }; -} + ], +}); From 1b84098bfeb4d94d9df7f8f24fab1e5202ab9a7f Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 23:09:01 +0200 Subject: [PATCH 29/54] fix(compute-providers): make metadata cleanup idempotent --- .../src/control-plane/runner-metadata.test.ts | 32 +++++++++++++++++++ .../src/control-plane/runner-metadata.ts | 9 +++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts index d9db41e4a4..10a1fa9349 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts @@ -175,6 +175,38 @@ describe('MicroVM metadata lifecycle', () => { ]); }); + it('continues deleting metadata when optional parameters are already absent', async () => { + vi.mocked(deleteParameter) + .mockRejectedValueOnce( + Object.assign(new Error('ParameterNotFound'), { + __type: 'ParameterNotFound', + $fault: 'client', + $metadata: { httpStatusCode: 400 }, + }), + ) + .mockRejectedValueOnce(Object.assign(new Error('missing parameter'), { name: 'ParameterNotFound' })); + + await expect(deleteMicrovmRunnerMetadata(metadataSsmPath, 'mvm-1')).resolves.toBeUndefined(); + expect(vi.mocked(deleteParameter).mock.calls.map(([name]) => name)).toEqual([ + `${metadataSsmPath}/mvm-1.github-runner-id`, + `${metadataSsmPath}/mvm-1.orphan`, + `${metadataSsmPath}/mvm-1.cleanup-requested-at`, + `${metadataSsmPath}/mvm-1`, + ]); + }); + + it('propagates metadata deletion failures other than missing parameters', async () => { + const error = Object.assign(new Error('AccessDeniedException'), { + __type: 'AccessDeniedException', + $fault: 'client', + $metadata: { httpStatusCode: 400 }, + }); + vi.mocked(deleteParameter).mockRejectedValueOnce(error); + + await expect(deleteMicrovmRunnerMetadata(metadataSsmPath, 'mvm-1')).rejects.toBe(error); + expect(deleteParameter).toHaveBeenCalledTimes(1); + }); + it('returns tracked and state-only active cleanup requests for termination retry', async () => { vi.mocked(getParametersByPath).mockResolvedValue( new Map([ diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts index 3974334e07..965b0222d8 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -88,6 +88,13 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isParameterNotFound(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'ParameterNotFound' || ('__type' in error && error.__type === 'ParameterNotFound')) + ); +} + function optionalString(value: unknown): value is string | undefined { return value === undefined || (typeof value === 'string' && value.length > 0); } @@ -342,7 +349,7 @@ export async function deleteMicrovmRunnerMetadata(metadataSsmPath: string, micro try { await deleteParameter(parameterName); } catch (error) { - if (!(error instanceof Error && error.name === 'ParameterNotFound')) throw error; + if (!isParameterNotFound(error)) throw error; } } } From 298f663a2979d0122392e9e64b20ae1ab127fb54 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 19 Aug 2026 23:41:22 +0200 Subject: [PATCH 30/54] fix(compute-providers): remove MicroVM duration label --- .../compute-providers/aws/microvm/README.md | 16 +++++-------- .../src/control-plane/runner-config.test.ts | 5 ++-- .../src/control-plane/scale-up.test.ts | 15 ++++++------ .../aws/microvm/src/dynamic-labels.test.ts | 8 +++---- .../aws/microvm/src/dynamic-labels.ts | 14 ----------- .../src/webhook/dynamic-labels.test.ts | 24 +++++++++++-------- .../aws/microvm/webhook.test.ts | 13 +++++++--- .../test/webhook-provider-contract.ts | 18 ++++++++++---- 8 files changed, 57 insertions(+), 56 deletions(-) diff --git a/lambdas/libs/compute-providers/aws/microvm/README.md b/lambdas/libs/compute-providers/aws/microvm/README.md index 71e53f9f7b..87a1af6e50 100644 --- a/lambdas/libs/compute-providers/aws/microvm/README.md +++ b/lambdas/libs/compute-providers/aws/microvm/README.md @@ -60,12 +60,11 @@ separate roles, prefixes, and provider deployments. When a runner matcher enables dynamic labels, workflow jobs can override the following `RunMicrovm` inputs: -| Label | Override | -| --------------------------------------------------- | ---------------------------------------------- | -| `ghr-microvm-egress-network-connectors:` | One egress network connector ARN | -| `ghr-microvm-image-arn:` | MicroVM image ARN | -| `ghr-microvm-image-version:` | MicroVM image version | -| `ghr-microvm-maximum-duration-in-seconds:` | Maximum lifetime from 1 through 28,800 seconds | +| Label | Override | +| --------------------------------------------- | -------------------------------- | +| `ghr-microvm-egress-network-connectors:` | One egress network connector ARN | +| `ghr-microvm-image-arn:` | MicroVM image ARN | +| `ghr-microvm-image-version:` | MicroVM image version | Repeat `ghr-microvm-egress-network-connectors:` to attach multiple connectors. Specify one ARN per label; `RunMicrovm` accepts at most 10. These @@ -84,7 +83,7 @@ explicit `allowed` list for the corresponding key. Use the matcher's `awsDynamicLabelsPolicy` to restrict values accepted from workflow jobs. The MicroVM policy keys are `egress-network-connectors`, -`image-arn`, `image-version`, and `maximum-duration-in-seconds`. For example: +`image-arn`, and `image-version`. For example: ```json { @@ -97,9 +96,6 @@ workflow jobs. The MicroVM policy keys are `egress-network-connectors`, }, "image-version": { "allowed": ["3.*"] - }, - "maximum-duration-in-seconds": { - "max": 3600 } } } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts index 2d4252ac71..940b1e2771 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts @@ -138,7 +138,7 @@ describe('createMicrovmRunners', () => { expect(setMicrovmGithubRunnerId).toHaveBeenNthCalledWith(1, metadataSsmPath, 'mvm-1', 'github-mvm-1'); }); - it('applies dynamic labels to the RunMicrovm configuration and metadata tags', async () => { + it('applies dynamic labels without overriding the deployment-controlled duration', async () => { const overrideImageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-large'; const overrideEgressConnectorArn = 'arn:aws:lambda:eu-west-1:123456789012:network-connector:github-runner-private-egress'; @@ -151,7 +151,6 @@ describe('createMicrovmRunners', () => { egressNetworkConnectors: [overrideEgressConnectorArn], imageIdentifier: overrideImageArn, imageVersion: '3.0', - maximumDurationInSeconds: 7200, }); expect(runMicrovmRunner).toHaveBeenCalledWith({ @@ -160,7 +159,7 @@ describe('createMicrovmRunners', () => { imageIdentifier: overrideImageArn, imageVersion: '3.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', - maximumDurationInSeconds: 7200, + maximumDurationInSeconds: 1200, metadataSsmPath, }, environment: 'unit-test', diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts index cfbbda3257..bd10efd41e 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts @@ -50,32 +50,33 @@ describe('createMicrovmScaleUpProvider', () => { `ghr-microvm-egress-network-connectors:${overrideEgressConnectorArn}`, `ghr-microvm-image-arn:${overrideImageArn}`, 'ghr-microvm-image-version:3.0', - 'ghr-microvm-maximum-duration-in-seconds:7200', ]), ).resolves.toEqual({ runnerLabels: [ `ghr-microvm-egress-network-connectors:${overrideEgressConnectorArn}`, `ghr-microvm-image-arn:${overrideImageArn}`, 'ghr-microvm-image-version:3.0', - 'ghr-microvm-maximum-duration-in-seconds:7200', ], state: { overrides: { egressNetworkConnectors: [overrideEgressConnectorArn], imageIdentifier: overrideImageArn, imageVersion: '3.0', - maximumDurationInSeconds: 7200, }, }, }); }); - it('rejects unsupported MicroVM override labels at the control-plane boundary', async () => { + it.each([ + ['ghr-microvm-memory:8192', "key 'memory' is not a supported MicroVM override"], + [ + 'ghr-microvm-maximum-duration-in-seconds:7200', + "key 'maximum-duration-in-seconds' is not a supported MicroVM override", + ], + ])('rejects unsupported MicroVM override label %s at the control-plane boundary', async (label, reason) => { const provider = createMicrovmScaleUpProvider(createStartRunnerConfig); - await expect(provider.resolveLabelsForRunners(['ghr-microvm-memory:8192'])).rejects.toThrow( - "key 'memory' is not a supported MicroVM override", - ); + await expect(provider.resolveLabelsForRunners([label])).rejects.toThrow(reason); }); it('counts managed MicroVMs for the runner owner', async () => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.test.ts index 6ea669a143..442986a0f8 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.test.ts @@ -15,14 +15,12 @@ describe('parseMicrovmDynamicLabels', () => { `ghr-microvm-egress-network-connectors:${internetEgressConnectorArn}`, `ghr-microvm-image-arn:${imageArn}`, 'ghr-microvm-image-version:3.0', - 'ghr-microvm-maximum-duration-in-seconds:7200', ]), ).toEqual({ overrides: { egressNetworkConnectors: [egressConnectorArn, internetEgressConnectorArn], imageIdentifier: imageArn, imageVersion: '3.0', - maximumDurationInSeconds: 7200, }, violations: [], }); @@ -40,8 +38,10 @@ describe('parseMicrovmDynamicLabels', () => { ], ['ghr-microvm-image-arn:not-an-arn', 'is not a valid customer MicroVM image ARN'], ['ghr-microvm-image-version:', "key 'image-version' requires a value"], - ['ghr-microvm-maximum-duration-in-seconds:0', 'maximum duration must be an integer between 1 and 28800'], - ['ghr-microvm-maximum-duration-in-seconds:28801', 'maximum duration must be an integer between 1 and 28800'], + [ + 'ghr-microvm-maximum-duration-in-seconds:7200', + "key 'maximum-duration-in-seconds' is not a supported MicroVM override", + ], ])('rejects invalid override %s', (label, reason) => { const result = parseMicrovmDynamicLabels([label]); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.ts b/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.ts index 50c223cfd2..2851716149 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/dynamic-labels.ts @@ -1,6 +1,5 @@ export const MICROVM_DYNAMIC_LABEL_PREFIX = 'ghr-microvm-'; -const MAXIMUM_DURATION_IN_SECONDS = 28_800; const MAXIMUM_EGRESS_NETWORK_CONNECTORS = 10; const MICROVM_IMAGE_ARN_PATTERN = /^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$/; const MICROVM_NETWORK_CONNECTOR_ARN_PATTERN = @@ -10,7 +9,6 @@ export interface MicrovmDynamicLabelOverrides { egressNetworkConnectors?: string[]; imageIdentifier?: string; imageVersion?: string; - maximumDurationInSeconds?: number; } export interface MicrovmDynamicLabelViolation { @@ -69,18 +67,6 @@ export function parseMicrovmDynamicLabels(labels: string[]): { case 'image-version': overrides.imageVersion = value; break; - case 'maximum-duration-in-seconds': { - const duration = Number(value); - if (!Number.isInteger(duration) || duration < 1 || duration > MAXIMUM_DURATION_IN_SECONDS) { - violations.push({ - label, - reason: `maximum duration must be an integer between 1 and ${MAXIMUM_DURATION_IN_SECONDS}`, - }); - } else { - overrides.maximumDurationInSeconds = duration; - } - break; - } default: violations.push({ label, reason: `key '${key}' is not a supported MicroVM override` }); } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts index b21ad792f3..2b7b85d74e 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/webhook/dynamic-labels.test.ts @@ -20,7 +20,6 @@ describe('microvmDynamicLabelProvider', () => { `ghr-microvm-egress-network-connectors:${egressConnectorArn}`, `ghr-microvm-image-arn:${imageArn}`, 'ghr-microvm-image-version:3.0', - 'ghr-microvm-maximum-duration-in-seconds:7200', ]; expect(getViolations(queue, dynamicLabels)).toEqual([]); @@ -32,7 +31,6 @@ describe('microvmDynamicLabelProvider', () => { `ghr-microvm-egress-network-connectors:${egressConnectorArn}`, `ghr-microvm-image-arn:${imageArn}`, 'ghr-microvm-image-version:3.0', - 'ghr-microvm-maximum-duration-in-seconds:3600', ]), ).toEqual([ { @@ -50,11 +48,17 @@ describe('microvmDynamicLabelProvider', () => { ]); }); - it('preserves violations from the MicroVM label parser', () => { - expect(getViolations(microvmQueue(), ['ghr-microvm-memory:8192'])).toEqual([ + it.each([ + ['ghr-microvm-memory:8192', "key 'memory' is not a supported MicroVM override"], + [ + 'ghr-microvm-maximum-duration-in-seconds:7200', + "key 'maximum-duration-in-seconds' is not a supported MicroVM override", + ], + ])('preserves the parser violation for %s', (label, reason) => { + expect(getViolations(microvmQueue(), [label])).toEqual([ { - label: 'ghr-microvm-memory:8192', - reason: "key 'memory' is not a supported MicroVM override", + label, + reason, }, ]); }); @@ -62,13 +66,13 @@ describe('microvmDynamicLabelProvider', () => { it('enforces the AWS dynamic-label policy', () => { const queue = microvmQueue(); queue.matcherConfig.awsDynamicLabelsPolicy = { - restricted_keys: { 'maximum-duration-in-seconds': { max: 3600 } }, + restricted_keys: { 'image-version': { allowed: ['2.*'] } }, }; - expect(getViolations(queue, ['ghr-microvm-maximum-duration-in-seconds:7200'])).toEqual([ + expect(getViolations(queue, ['ghr-microvm-image-version:3.0'])).toEqual([ { - label: 'ghr-microvm-maximum-duration-in-seconds:7200', - reason: "value '7200' exceeds max '3600'", + label: 'ghr-microvm-image-version:3.0', + reason: "value '3.0' not in allowed list", }, ]); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts b/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts index efe6bcd070..ad3baed78b 100644 --- a/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/webhook.test.ts @@ -3,13 +3,20 @@ import { provider } from './webhook'; defineWebhookProviderContractTests({ provider, - acceptedDynamicLabels: ['ghr-microvm-maximum-duration-in-seconds:3600'], + acceptedDynamicLabels: ['ghr-microvm-image-version:3.0'], + configureQueue: (queue) => { + queue.matcherConfig.awsDynamicLabelsPolicy = { + restricted_keys: { + 'image-version': { allowed: ['3.0'] }, + }, + }; + }, rejectingPolicies: [ { name: 'blocked keys', apply: (queue) => { queue.matcherConfig.awsDynamicLabelsPolicy = { - blocked_keys: ['maximum-duration-in-seconds'], + blocked_keys: ['image-version'], }; }, }, @@ -18,7 +25,7 @@ defineWebhookProviderContractTests({ apply: (queue) => { queue.matcherConfig.awsDynamicLabelsPolicy = { restricted_keys: { - 'maximum-duration-in-seconds': { max: 1800 }, + 'image-version': { allowed: ['2.*'] }, }, }; }, diff --git a/lambdas/libs/compute-providers/test/webhook-provider-contract.ts b/lambdas/libs/compute-providers/test/webhook-provider-contract.ts index dd4e3097b0..a6d01b7e6e 100644 --- a/lambdas/libs/compute-providers/test/webhook-provider-contract.ts +++ b/lambdas/libs/compute-providers/test/webhook-provider-contract.ts @@ -13,17 +13,25 @@ interface RejectingPolicyCase { interface WebhookProviderContractOptions { provider: WebhookProviderModule; acceptedDynamicLabels: readonly [string, ...string[]]; + configureQueue?(queue: RunnerMatcherConfig): void; rejectingPolicies: readonly [RejectingPolicyCase, ...RejectingPolicyCase[]]; } export function defineWebhookProviderContractTests({ provider, acceptedDynamicLabels, + configureQueue, rejectingPolicies, }: WebhookProviderContractOptions): void { const nonGhrLabels = ['self-hosted', 'linux']; const dynamicLabels = [...acceptedDynamicLabels]; + function configuredRunnerQueue(id: string, computeProvider?: ComputeProviderType): RunnerMatcherConfig { + const queue = runnerQueue(id, computeProvider); + configureQueue?.(queue); + return queue; + } + function expectProviderSelected(queue: RunnerMatcherConfig) { expect(selectDynamicLabelQueue([queue], nonGhrLabels, dynamicLabels)).toEqual({ queue, @@ -33,11 +41,11 @@ export function defineWebhookProviderContractTests { it('selects an explicitly configured provider through the production registry', () => { - expectProviderSelected(runnerQueue(`${provider.type}-configured`, provider.type)); + expectProviderSelected(configuredRunnerQueue(`${provider.type}-configured`, provider.type)); }); it('skips the provider when dynamic labels are disabled', () => { - const queue = runnerQueue(`${provider.type}-disabled`, provider.type); + const queue = configuredRunnerQueue(`${provider.type}-disabled`, provider.type); queue.matcherConfig.enableDynamicLabels = false; expect(selectDynamicLabelQueue([queue], nonGhrLabels, dynamicLabels)).toBeUndefined(); @@ -45,7 +53,7 @@ export function defineWebhookProviderContractTests { - const queue = runnerQueue(`${provider.type}-policy-rejected`, provider.type); + const queue = configuredRunnerQueue(`${provider.type}-policy-rejected`, provider.type); policy.apply(queue); expect(selectDynamicLabelQueue([queue], nonGhrLabels, dynamicLabels)).toBeUndefined(); @@ -53,7 +61,7 @@ export function defineWebhookProviderContractTests { - const queue = runnerQueue(`${provider.type}-normalized`); + const queue = configuredRunnerQueue(`${provider.type}-normalized`); (queue as unknown as { computeProvider: string }).computeProvider = ` ${provider.type.toUpperCase()} `; expectProviderSelected(queue); @@ -61,7 +69,7 @@ export function defineWebhookProviderContractTests { - expectProviderSelected(runnerQueue(`${provider.type}-default`)); + expectProviderSelected(configuredRunnerQueue(`${provider.type}-default`)); }); } }); From fc8450c2aed27a5091bc425f61613beca6f02fd0 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 20 Aug 2026 16:27:14 +0200 Subject: [PATCH 31/54] fix(compute-providers): fix MicroVM lifetime at eight hours --- .../compute-providers/aws/microvm/README.md | 3 ++- .../microvm/src/control-plane/config.test.ts | 14 +----------- .../aws/microvm/src/control-plane/config.ts | 22 ------------------- .../aws/microvm/src/control-plane/lifetime.ts | 1 + .../src/control-plane/microvms.test.ts | 8 +++---- .../aws/microvm/src/control-plane/microvms.ts | 4 ++-- .../src/control-plane/runner-config.test.ts | 5 +---- .../src/control-plane/runner-metadata.test.ts | 3 +-- .../src/control-plane/runner-metadata.ts | 4 ++-- .../src/control-plane/scale-down.test.ts | 1 - .../aws/microvm/src/environment.d.ts | 1 - 11 files changed, 14 insertions(+), 52 deletions(-) create mode 100644 lambdas/libs/compute-providers/aws/microvm/src/control-plane/lifetime.ts diff --git a/lambdas/libs/compute-providers/aws/microvm/README.md b/lambdas/libs/compute-providers/aws/microvm/README.md index 87a1af6e50..b2486b695f 100644 --- a/lambdas/libs/compute-providers/aws/microvm/README.md +++ b/lambdas/libs/compute-providers/aws/microvm/README.md @@ -29,10 +29,11 @@ The control-plane Lambda requires these provider environment variables: - `MICROVM_IMAGE_VERSION` (optional) - `MICROVM_INGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) - `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) -- `MICROVM_MAXIMUM_DURATION_IN_SECONDS` (optional, defaults to 3600) - `MICROVM_METADATA_SSM_PATH` (dedicated SSM path for control-plane metadata) - `MICROVM_LOG_GROUP` (optional) +Each runner is launched with a fixed lifetime of 28,800 seconds (8 hours). + The control-plane role requires `ssm:GetParametersByPath`, `ssm:PutParameter`, and `ssm:DeleteParameter` on the dedicated metadata prefix, plus `lambda:ListMicrovms`, `lambda:RunMicrovm`, and `lambda:TerminateMicrovm` for diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts index 1cc240a9f7..b68fdffb7c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts @@ -12,7 +12,6 @@ beforeEach(() => { delete process.env.MICROVM_IMAGE_VERSION; delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS; delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS; - delete process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS; delete process.env.MICROVM_LOG_GROUP; }); @@ -24,24 +23,21 @@ describe('loadMicrovmProviderConfig', () => { executionRoleArn: process.env.MICROVM_EXECUTION_ROLE_ARN, ingressNetworkConnectors: undefined, egressNetworkConnectors: undefined, - maximumDurationInSeconds: 3600, metadataSsmPath: '/github-action-runners/unit-test/microvm-metadata', logging: undefined, }); }); - it('loads versions, logging, duration, and either connector list format', () => { + it('loads versions, logging, and either connector list format', () => { process.env.MICROVM_IMAGE_VERSION = ' 3.0 '; process.env.MICROVM_INGRESS_NETWORK_CONNECTORS = '["arn:ingress:one","arn:ingress:two"]'; process.env.MICROVM_EGRESS_NETWORK_CONNECTORS = 'arn:egress:one, arn:egress:two'; - process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS = '1200'; process.env.MICROVM_LOG_GROUP = ' /aws/lambda-microvms/runner '; expect(loadMicrovmProviderConfig()).toMatchObject({ imageVersion: '3.0', ingressNetworkConnectors: ['arn:ingress:one', 'arn:ingress:two'], egressNetworkConnectors: ['arn:egress:one', 'arn:egress:two'], - maximumDurationInSeconds: 1200, logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, }); }); @@ -58,14 +54,6 @@ describe('loadMicrovmProviderConfig', () => { ); }); - it.each(['0', '28801', '1.5', 'invalid'])('rejects invalid maximum duration %s', (duration) => { - process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS = duration; - - expect(() => loadMicrovmProviderConfig()).toThrow( - 'MICROVM_MAXIMUM_DURATION_IN_SECONDS must be an integer between 1 and 28800', - ); - }); - it.each(['[not-json', '[]', '["valid", 2]', 'first,'])('rejects malformed connector lists %s', (connectors) => { process.env.MICROVM_EGRESS_NETWORK_CONNECTORS = connectors; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts index ddd7891362..8eacada06c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts @@ -1,8 +1,5 @@ import type { Logging, RunMicrovmCommandInput } from '@aws-sdk/client-lambda-microvms'; -const DEFAULT_MAXIMUM_DURATION_IN_SECONDS = 3600; -const MAXIMUM_DURATION_IN_SECONDS = 28800; - export interface MicrovmProviderConfig { egressNetworkConnectors?: string[]; executionRoleArn: string; @@ -10,7 +7,6 @@ export interface MicrovmProviderConfig { imageVersion?: string; ingressNetworkConnectors?: string[]; logging?: Logging; - maximumDurationInSeconds: number; metadataSsmPath: string; } @@ -59,23 +55,6 @@ function parseNetworkConnectors(name: string, value: string | undefined): string return connectors.map((connector) => connector.trim()); } -function parseMaximumDuration(value: string | undefined): number { - if (!optionalEnvironmentValue(value)) return DEFAULT_MAXIMUM_DURATION_IN_SECONDS; - - const maximumDurationInSeconds = Number(value); - if ( - !Number.isInteger(maximumDurationInSeconds) || - maximumDurationInSeconds < 1 || - maximumDurationInSeconds > MAXIMUM_DURATION_IN_SECONDS - ) { - throw new Error( - `MICROVM_MAXIMUM_DURATION_IN_SECONDS must be an integer between 1 and ${MAXIMUM_DURATION_IN_SECONDS}`, - ); - } - - return maximumDurationInSeconds; -} - export function loadMicrovmProviderConfig(): MicrovmProviderConfig { const logGroup = optionalEnvironmentValue(process.env.MICROVM_LOG_GROUP); @@ -91,7 +70,6 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig { 'MICROVM_EGRESS_NETWORK_CONNECTORS', process.env.MICROVM_EGRESS_NETWORK_CONNECTORS, ), - maximumDurationInSeconds: parseMaximumDuration(process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS), metadataSsmPath: parseMetadataSsmPath(process.env.MICROVM_METADATA_SSM_PATH), logging: logGroup ? ({ cloudWatch: { logGroup } } satisfies RunMicrovmCommandInput['logging']) : undefined, }; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/lifetime.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/lifetime.ts new file mode 100644 index 0000000000..09b6a46f8d --- /dev/null +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/lifetime.ts @@ -0,0 +1 @@ +export const MICROVM_LIFETIME_IN_SECONDS = 28_800; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts index cd4fe86250..142adf1695 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts @@ -39,7 +39,6 @@ const config: MicrovmProviderConfig = { imageVersion: '3.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', egressNetworkConnectors: ['arn:egress'], - maximumDurationInSeconds: 1200, metadataSsmPath, logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, }; @@ -64,6 +63,7 @@ beforeEach(() => { mockMicrovmClient.reset(); vi.clearAllMocks(); vi.useRealTimers(); + delete process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS; process.env.AWS_REGION = 'eu-west-1'; process.env.RUNNER_BOOT_TIME_IN_MINUTES = '5'; vi.mocked(createMicrovmRunnerMetadata).mockResolvedValue(); @@ -73,7 +73,8 @@ beforeEach(() => { }); describe('runMicrovmRunner', () => { - it('launches a runner and records durable ownership metadata', async () => { + it('launches a runner for the fixed lifetime and records durable ownership metadata', async () => { + process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS = '1200'; mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-123', imageArn }); await expect( @@ -92,7 +93,7 @@ describe('runMicrovmRunner', () => { imageVersion: '3.0', executionRoleArn: config.executionRoleArn, egressNetworkConnectors: ['arn:egress'], - maximumDurationInSeconds: 1200, + maximumDurationInSeconds: 28_800, logging: config.logging, runHookPayload: '{"version":1}', clientToken: expect.any(String), @@ -105,7 +106,6 @@ describe('runMicrovmRunner', () => { source: 'scale-up-lambda', imageArn, imageVersion: '3.0', - maximumDurationInSeconds: 1200, }); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index 3769d487c7..fb61111d4f 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -11,6 +11,7 @@ import type { MicrovmItem, MicrovmState, RunMicrovmCommandInput } from '@aws-sdk import type { LambdaRunnerSource, ListRunnerFilters, RunnerInfo, RunnerType } from '../../../../core'; import { loadMicrovmProviderConfig, type MicrovmProviderConfig } from './config'; +import { MICROVM_LIFETIME_IN_SECONDS } from './lifetime'; import { createMicrovmRunnerMetadata, deleteMicrovmRunnerMetadata, @@ -77,7 +78,7 @@ export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { vi.mocked(loadMicrovmProviderConfig).mockReturnValue({ imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', - maximumDurationInSeconds: 1200, metadataSsmPath, }); vi.mocked(runMicrovmRunner).mockResolvedValue('mvm-1'); @@ -93,7 +92,6 @@ describe('createMicrovmRunners', () => { vi.mocked(loadMicrovmProviderConfig).mockReturnValue({ imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', - maximumDurationInSeconds: 1200, metadataSsmPath: '/github-action-runners/unit-test/token/metadata', }); @@ -138,7 +136,7 @@ describe('createMicrovmRunners', () => { expect(setMicrovmGithubRunnerId).toHaveBeenNthCalledWith(1, metadataSsmPath, 'mvm-1', 'github-mvm-1'); }); - it('applies dynamic labels without overriding the deployment-controlled duration', async () => { + it('applies supported dynamic labels to the provider configuration', async () => { const overrideImageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-large'; const overrideEgressConnectorArn = 'arn:aws:lambda:eu-west-1:123456789012:network-connector:github-runner-private-egress'; @@ -159,7 +157,6 @@ describe('createMicrovmRunners', () => { imageIdentifier: overrideImageArn, imageVersion: '3.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', - maximumDurationInSeconds: 1200, metadataSsmPath, }, environment: 'unit-test', diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts index 10a1fa9349..de7c37692c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts @@ -80,12 +80,11 @@ describe('MicroVM metadata lifecycle', () => { source: 'scale-up-lambda', imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', imageVersion: '3.0', - maximumDurationInSeconds: 1200, }); expect(putParameter).toHaveBeenCalledWith( `${metadataSsmPath}/mvm-1`, - JSON.stringify(metadata({ expiresAt: '2026-08-19T10:25:00.000Z' })), + JSON.stringify(metadata({ expiresAt: '2026-08-19T18:05:00.000Z' })), false, ); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts index 965b0222d8..f6ef69f390 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -3,6 +3,7 @@ import { deleteParameter, getParametersByPath, putParameter } from '@aws-github- import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; import type { LambdaRunnerSource, RunnerType } from '../../../../core'; +import { MICROVM_LIFETIME_IN_SECONDS } from './lifetime'; const logger = createChildLogger('microvm-runner-metadata'); @@ -40,7 +41,6 @@ export interface CreateMicrovmRunnerMetadataInput { environment: string; imageArn: string; imageVersion?: string; - maximumDurationInSeconds: number; microvmId: string; runnerOwner: string; runnerType: RunnerType; @@ -166,7 +166,7 @@ export async function createMicrovmRunnerMetadata( imageVersion: input.imageVersion, createdAt: createdAt.toISOString(), expiresAt: new Date( - createdAt.getTime() + (input.maximumDurationInSeconds + EXPIRATION_GRACE_IN_SECONDS) * 1000, + createdAt.getTime() + (MICROVM_LIFETIME_IN_SECONDS + EXPIRATION_GRACE_IN_SECONDS) * 1000, ).toISOString(), }; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts index fce2d06137..02818fb939 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts @@ -18,7 +18,6 @@ const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; const providerConfig = { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', - maximumDurationInSeconds: 1200, metadataSsmPath, }; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts index 06668b935f..58cf080e5e 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts @@ -9,7 +9,6 @@ declare global { MICROVM_IMAGE_VERSION: string | undefined; MICROVM_INGRESS_NETWORK_CONNECTORS: string | undefined; MICROVM_LOG_GROUP: string | undefined; - MICROVM_MAXIMUM_DURATION_IN_SECONDS: string | undefined; MICROVM_METADATA_SSM_PATH: string; } } From 3debf8e30d128e6edfcbdd8bf68523d9779ceaa2 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 21 Aug 2026 11:58:33 +0200 Subject: [PATCH 32/54] feat(microvm): tag runner metadata --- .../src/scale-runners/scale-up.test.ts | 19 +++ lambdas/libs/aws-ssm-util/src/index.test.ts | 26 +++ lambdas/libs/aws-ssm-util/src/index.ts | 13 ++ .../compute-providers/aws/microvm/README.md | 47 ++++-- .../microvm/src/control-plane/config.test.ts | 23 +++ .../aws/microvm/src/control-plane/config.ts | 42 +++++ .../src/control-plane/microvms.test.ts | 33 +++- .../aws/microvm/src/control-plane/microvms.ts | 31 +++- .../src/control-plane/runner-config.test.ts | 31 +++- .../src/control-plane/runner-config.ts | 7 +- .../src/control-plane/runner-metadata.test.ts | 125 +++++++++++++- .../src/control-plane/runner-metadata.ts | 155 +++++++++++++++++- .../src/control-plane/scale-down.test.ts | 1 + .../aws/microvm/src/environment.d.ts | 1 + 14 files changed, 508 insertions(+), 46 deletions(-) diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 664bac60fb..d6b2d6a096 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -417,6 +417,25 @@ describe('scaleUp with GHES', () => { }); }); + it.each([true, false])( + 'keeps the provider runner identity tag authoritative for ephemeral=%s', + async (ephemeral) => { + process.env.ENABLE_EPHEMERAL_RUNNERS = String(ephemeral); + process.env.RUNNERS_MAXIMUM_COUNT = '2'; + process.env.SSM_PARAMETER_STORE_TAGS = JSON.stringify([ + { Key: 'RunnerId', Value: 'configured-value-cannot-win' }, + { Key: 'CostCenter', Value: '1234' }, + ]); + + await scaleUpModule.scaleUp(TEST_DATA); + + expect(mockSSMClient.commandCalls(PutParameterCommand)[0].args[0].input.Tags).toEqual([ + { Key: 'RunnerId', Value: 'i-12345' }, + { Key: 'CostCenter', Value: '1234' }, + ]); + }, + ); + it('quotes runner labels with semicolon separators in non-ephemeral runner config', async () => { process.env.ENABLE_EPHEMERAL_RUNNERS = 'false'; process.env.RUNNERS_MAXIMUM_COUNT = '2'; diff --git a/lambdas/libs/aws-ssm-util/src/index.test.ts b/lambdas/libs/aws-ssm-util/src/index.test.ts index ad68c12279..2f6080bb8a 100644 --- a/lambdas/libs/aws-ssm-util/src/index.test.ts +++ b/lambdas/libs/aws-ssm-util/src/index.test.ts @@ -1,4 +1,5 @@ import { + AddTagsToResourceCommand, DeleteParameterCommand, GetParameterCommand, GetParameterCommandOutput, @@ -13,6 +14,7 @@ import { mockClient } from 'aws-sdk-client-mock'; import nock from 'nock'; import { + addParameterTags, deleteParameter, getParameter, getParameters, @@ -329,6 +331,30 @@ describe('Test direct parameter path operations', () => { expect(mockSSMClient).toHaveReceivedCommandWith(DeleteParameterCommand, { Name: '/metadata/one' }); }); + + it('adds tags to an exact parameter name', async () => { + mockSSMClient.on(AddTagsToResourceCommand).resolves({}); + + await addParameterTags('/metadata/one', [{ Key: 'ghr:environment', Value: 'unit-test' }]); + + expect(mockSSMClient).toHaveReceivedCommandWith(AddTagsToResourceCommand, { + ResourceType: 'Parameter', + ResourceId: '/metadata/one', + Tags: [{ Key: 'ghr:environment', Value: 'unit-test' }], + }); + }); + + it('does not call SSM when there are no parameter tags to add', async () => { + await addParameterTags('/metadata/one', []); + + expect(mockSSMClient).not.toHaveReceivedCommand(AddTagsToResourceCommand); + }); + + it('propagates failures when adding parameter tags', async () => { + mockSSMClient.on(AddTagsToResourceCommand).rejects(new Error('AccessDenied')); + + await expect(addParameterTags('/metadata/one', [{ Key: 'Name', Value: 'runner' }])).rejects.toThrow('AccessDenied'); + }); }); describe('SSM client configuration', () => { diff --git a/lambdas/libs/aws-ssm-util/src/index.ts b/lambdas/libs/aws-ssm-util/src/index.ts index 9fef6c7b97..ad448b57ac 100644 --- a/lambdas/libs/aws-ssm-util/src/index.ts +++ b/lambdas/libs/aws-ssm-util/src/index.ts @@ -1,4 +1,5 @@ import { + AddTagsToResourceCommand, DeleteParameterCommand, GetParametersByPathCommand, GetParametersCommand, @@ -146,6 +147,18 @@ export async function deleteParameter(parameter_name: string): Promise { await ssmClient().send(new DeleteParameterCommand({ Name: parameter_name })); } +export async function addParameterTags(parameter_name: string, tags: Tag[]): Promise { + if (tags.length === 0) return; + + await ssmClient().send( + new AddTagsToResourceCommand({ + ResourceType: 'Parameter', + ResourceId: parameter_name, + Tags: tags, + }), + ); +} + export const SSM_ADVANCED_TIER_THRESHOLD = 4000; type PutParameterOptions = { overwrite: true; tags?: never } | { overwrite?: false | undefined; tags?: Tag[] }; diff --git a/lambdas/libs/compute-providers/aws/microvm/README.md b/lambdas/libs/compute-providers/aws/microvm/README.md index b2486b695f..1f70ec75e4 100644 --- a/lambdas/libs/compute-providers/aws/microvm/README.md +++ b/lambdas/libs/compute-providers/aws/microvm/README.md @@ -11,7 +11,7 @@ The MicroVM image `/run` hook receives this `runHookPayload`: } ``` -Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and terminate the MicroVM after the job completes. +Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and exit its lifecycle entrypoint after the job completes. Trusted control-plane cleanup and the fixed lifetime remain termination backstops. Runner ownership and lifecycle state are stored separately as non-secret `String` parameters under `/`. The immutable base @@ -22,6 +22,19 @@ overlap the JIT path, and do not grant the MicroVM execution role access to it. The control plane retries pending cleanup, removes metadata after termination, and reconciles expired records during inventory. +The immutable base metadata parameter is also the canonical tag surface for a +runner. It merges `SSM_PARAMETER_STORE_TAGS` with the Terraform-generated +`MICROVM_METADATA_TAGS`. Terraform supplies `Name`, `ghr:environment`, +`ghr:ssm_config_path`, and `ghr:runner_name_prefix`; the Lambda then adds +authoritative runtime tags: +`ghr:Application`, `ghr:created_by`, `ghr:environment`, `ghr:Owner`, +`ghr:Type`, `ghr:microvm_id`, `ghr:microvm_image_arn`, and, when available, +`ghr:microvm_image_version`. After JIT registration, the control plane adds +`ghr:github_runner_id` and base64url-encoded runner-label groups under +`ghr:runner_labels` through `ghr:runner_labels:5`. Runtime-owned values override +configured collisions. The `aws:` tag prefix is reserved and cannot be used for +these SSM parameters. + The control-plane Lambda requires these provider environment variables: - `MICROVM_IMAGE_ARN` @@ -30,31 +43,33 @@ The control-plane Lambda requires these provider environment variables: - `MICROVM_INGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) - `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) - `MICROVM_METADATA_SSM_PATH` (dedicated SSM path for control-plane metadata) +- `MICROVM_METADATA_TAGS` (optional JSON array of base tags for the canonical metadata parameter) - `MICROVM_LOG_GROUP` (optional) Each runner is launched with a fixed lifetime of 28,800 seconds (8 hours). The control-plane role requires `ssm:GetParametersByPath`, `ssm:PutParameter`, -and `ssm:DeleteParameter` on the dedicated metadata prefix, plus -`lambda:ListMicrovms`, `lambda:RunMicrovm`, and `lambda:TerminateMicrovm` for -inventory and lifecycle reconciliation. Restrict `lambda:RunMicrovm` and -`lambda:TerminateMicrovm` to approved image resources; `lambda:ListMicrovms` -does not support resource-level permissions. +`ssm:AddTagsToResource`, and `ssm:DeleteParameter` on the dedicated metadata +prefix, plus `lambda:ListMicrovms`, `lambda:RunMicrovm`, and +`lambda:TerminateMicrovm` for inventory and lifecycle reconciliation. Restrict +`lambda:RunMicrovm` and `lambda:TerminateMicrovm` to approved image resources; +`lambda:ListMicrovms` does not support resource-level permissions. The MicroVM execution role must trust `lambda.amazonaws.com` for both `sts:AssumeRole` and `sts:TagSession`. Restrict `iam:PassRole` to that exact role -with `iam:PassedToService=lambda.amazonaws.com`. Egress connectors also require -`lambda:PassNetworkConnector`; because that action does not currently support -resource-level permissions, enforce the connector boundary with the explicit -dynamic-label allowlist described below. +ARN. Network connectors also require `lambda:PassNetworkConnector`; because +that action does not currently support resource-level permissions, enforce the +connector boundary with the explicit dynamic-label allowlist described below. All MicroVMs using one execution role and JIT prefix share a trust boundary. -Grant that role only `ssm:GetParameter` and `ssm:DeleteParameter` on the JIT -prefix; do not grant parameter-listing APIs or access to the metadata prefix. -The `MicrovmId` tag on each JIT parameter supports operations but is not a -documented binding to the calling MicroVM's session identity. Only allow trusted -images and workloads within a shared role, or isolate trust domains with -separate roles, prefixes, and provider deployments. +Grant that role only `ssm:GetParameter` and `ssm:DeleteParameter` on the +lane-scoped JIT prefix. The image must use the +exact `/` parameter name and must not receive +access to the metadata prefix or path-listing APIs. The `MicrovmId` tag on each +JIT parameter supports operations but is not a documented binding to the +calling MicroVM's session identity. Only allow trusted images and workloads +within a shared role, or isolate trust domains with separate roles, prefixes, +and provider deployments. ## Dynamic labels diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts index b68fdffb7c..9646fef794 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts @@ -9,6 +9,7 @@ beforeEach(() => { process.env.MICROVM_IMAGE_ARN = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/microvm-runner'; process.env.MICROVM_METADATA_SSM_PATH = '/github-action-runners/unit-test/microvm-metadata/'; + delete process.env.MICROVM_METADATA_TAGS; delete process.env.MICROVM_IMAGE_VERSION; delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS; delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS; @@ -24,6 +25,7 @@ describe('loadMicrovmProviderConfig', () => { ingressNetworkConnectors: undefined, egressNetworkConnectors: undefined, metadataSsmPath: '/github-action-runners/unit-test/microvm-metadata', + metadataTags: [], logging: undefined, }); }); @@ -33,11 +35,19 @@ describe('loadMicrovmProviderConfig', () => { process.env.MICROVM_INGRESS_NETWORK_CONNECTORS = '["arn:ingress:one","arn:ingress:two"]'; process.env.MICROVM_EGRESS_NETWORK_CONNECTORS = 'arn:egress:one, arn:egress:two'; process.env.MICROVM_LOG_GROUP = ' /aws/lambda-microvms/runner '; + process.env.MICROVM_METADATA_TAGS = JSON.stringify([ + { Key: 'Name', Value: 'unit-test-runner' }, + { Key: 'ghr:environment', Value: 'unit-test' }, + ]); expect(loadMicrovmProviderConfig()).toMatchObject({ imageVersion: '3.0', ingressNetworkConnectors: ['arn:ingress:one', 'arn:ingress:two'], egressNetworkConnectors: ['arn:egress:one', 'arn:egress:two'], + metadataTags: [ + { Key: 'Name', Value: 'unit-test-runner' }, + { Key: 'ghr:environment', Value: 'unit-test' }, + ], logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, }); }); @@ -70,4 +80,17 @@ describe('loadMicrovmProviderConfig', () => { ); }, ); + + it.each([ + '[not-json', + '{}', + '[{"Key":"Name"}]', + '[{"Key":"","Value":"runner"}]', + '[{"Key":"Name","Value":1}]', + '[{"Key":"Name","Value":"one"},{"Key":"Name","Value":"two"}]', + ])('rejects malformed metadata tags %s', (tags) => { + process.env.MICROVM_METADATA_TAGS = tags; + + expect(() => loadMicrovmProviderConfig()).toThrow(/MICROVM_METADATA_TAGS must/); + }); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts index 8eacada06c..bbf26df264 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts @@ -1,5 +1,10 @@ import type { Logging, RunMicrovmCommandInput } from '@aws-sdk/client-lambda-microvms'; +export interface MicrovmMetadataTag { + Key: string; + Value: string; +} + export interface MicrovmProviderConfig { egressNetworkConnectors?: string[]; executionRoleArn: string; @@ -8,6 +13,7 @@ export interface MicrovmProviderConfig { ingressNetworkConnectors?: string[]; logging?: Logging; metadataSsmPath: string; + metadataTags: MicrovmMetadataTag[]; } function requiredEnvironmentValue(name: string, value: string | undefined): string { @@ -55,6 +61,41 @@ function parseNetworkConnectors(name: string, value: string | undefined): string return connectors.map((connector) => connector.trim()); } +function parseMetadataTags(value: string | undefined): MicrovmMetadataTag[] { + const configuredValue = optionalEnvironmentValue(value); + if (!configuredValue) return []; + + let tags: unknown; + try { + tags = JSON.parse(configuredValue); + } catch (error) { + throw new Error('MICROVM_METADATA_TAGS must be a JSON array of SSM tag objects', { cause: error }); + } + + if ( + !Array.isArray(tags) || + tags.some( + (tag) => + typeof tag !== 'object' || + tag === null || + !('Key' in tag) || + typeof tag.Key !== 'string' || + tag.Key.length === 0 || + !('Value' in tag) || + typeof tag.Value !== 'string', + ) + ) { + throw new Error('MICROVM_METADATA_TAGS must be a JSON array of SSM tag objects'); + } + + const typedTags = tags as MicrovmMetadataTag[]; + if (new Set(typedTags.map((tag) => tag.Key)).size !== typedTags.length) { + throw new Error('MICROVM_METADATA_TAGS must not contain duplicate tag keys'); + } + + return typedTags; +} + export function loadMicrovmProviderConfig(): MicrovmProviderConfig { const logGroup = optionalEnvironmentValue(process.env.MICROVM_LOG_GROUP); @@ -71,6 +112,7 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig { process.env.MICROVM_EGRESS_NETWORK_CONNECTORS, ), metadataSsmPath: parseMetadataSsmPath(process.env.MICROVM_METADATA_SSM_PATH), + metadataTags: parseMetadataTags(process.env.MICROVM_METADATA_TAGS), logging: logGroup ? ({ cloudWatch: { logGroup } } satisfies RunMicrovmCommandInput['logging']) : undefined, }; } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts index 142adf1695..b90ee2f092 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts @@ -24,7 +24,8 @@ import { type MicrovmRunnerMetadata, } from './runner-metadata'; -vi.mock('./runner-metadata', () => ({ +vi.mock('./runner-metadata', async (importOriginal) => ({ + ...(await importOriginal()), createMicrovmRunnerMetadata: vi.fn(), deleteMicrovmRunnerMetadata: vi.fn(), listMicrovmRunnerMetadata: vi.fn(), @@ -40,8 +41,10 @@ const config: MicrovmProviderConfig = { executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', egressNetworkConnectors: ['arn:egress'], metadataSsmPath, + metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, }; +const ssmParameterStoreTags = [{ Key: 'CostCenter', Value: '1234' }]; function metadata(overrides: Partial = {}): MicrovmRunnerMetadata { return { @@ -75,7 +78,7 @@ beforeEach(() => { describe('runMicrovmRunner', () => { it('launches a runner for the fixed lifetime and records durable ownership metadata', async () => { process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS = '1200'; - mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-123', imageArn }); + mockMicrovmClient.on(RunMicrovmCommand).resolves({ microvmId: 'mvm-123', imageArn, imageVersion: '3.1' }); await expect( runMicrovmRunner({ @@ -84,6 +87,7 @@ describe('runMicrovmRunner', () => { runHookPayload: '{"version":1}', runnerOwner: 'Codertocat', runnerType: 'Org', + ssmParameterStoreTags, source: 'scale-up-lambda', }), ).resolves.toBe('mvm-123'); @@ -105,10 +109,30 @@ describe('runMicrovmRunner', () => { runnerType: 'Org', source: 'scale-up-lambda', imageArn, - imageVersion: '3.0', + imageVersion: '3.1', + metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], + ssmParameterStoreTags, }); }); + it('rejects invalid metadata tags before launching a MicroVM', async () => { + await expect( + runMicrovmRunner({ + config: { + ...config, + metadataTags: [{ Key: 'aws:microvm:image-arn', Value: imageArn }], + }, + environment: 'unit-test', + runHookPayload: '{}', + runnerOwner: 'Codertocat', + runnerType: 'Org', + ssmParameterStoreTags: [], + source: 'scale-up-lambda', + }), + ).rejects.toThrow('AWS-reserved tag prefix'); + expect(mockMicrovmClient).not.toHaveReceivedCommand(RunMicrovmCommand); + }); + it('rejects a launch response without an ID', async () => { mockMicrovmClient.on(RunMicrovmCommand).resolves({}); @@ -119,6 +143,7 @@ describe('runMicrovmRunner', () => { runHookPayload: '{}', runnerOwner: 'Codertocat', runnerType: 'Org', + ssmParameterStoreTags: [], source: 'pool-lambda', }), ).rejects.toThrow('RunMicrovm returned no microvmId'); @@ -136,6 +161,7 @@ describe('runMicrovmRunner', () => { runHookPayload: '{}', runnerOwner: 'Codertocat', runnerType: 'Org', + ssmParameterStoreTags: [], source: 'scale-up-lambda', }), ).rejects.toThrow('metadata failed'); @@ -157,6 +183,7 @@ describe('runMicrovmRunner', () => { runHookPayload: '{}', runnerOwner: 'Codertocat', runnerType: 'Org', + ssmParameterStoreTags: [], source: 'scale-up-lambda', }), ).rejects.toThrow('metadata failed'); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index fb61111d4f..58151698ff 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -9,10 +9,17 @@ import { } from '@aws-sdk/client-lambda-microvms'; import type { MicrovmItem, MicrovmState, RunMicrovmCommandInput } from '@aws-sdk/client-lambda-microvms'; -import type { LambdaRunnerSource, ListRunnerFilters, RunnerInfo, RunnerType } from '../../../../core'; +import type { + CreateGitHubRunnerConfig, + LambdaRunnerSource, + ListRunnerFilters, + RunnerInfo, + RunnerType, +} from '../../../../core'; import { loadMicrovmProviderConfig, type MicrovmProviderConfig } from './config'; import { MICROVM_LIFETIME_IN_SECONDS } from './lifetime'; import { + assertValidMicrovmMetadataTags, createMicrovmRunnerMetadata, deleteMicrovmRunnerMetadata, listMicrovmRunnerMetadata, @@ -34,6 +41,7 @@ export interface RunMicrovmRunnerInput { runHookPayload: string; runnerOwner: string; runnerType: RunnerType; + ssmParameterStoreTags: CreateGitHubRunnerConfig['ssmParameterStoreTags']; source: LambdaRunnerSource; } @@ -72,6 +80,18 @@ function microvmClient(): LambdaMicrovmsClient { } export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { + assertValidMicrovmMetadataTags({ + microvmId: 'microvm-validation', + environment: input.environment, + runnerOwner: input.runnerOwner, + runnerType: input.runnerType, + source: input.source, + imageArn: input.config.imageIdentifier, + imageVersion: input.config.imageVersion ?? 'version-validation', + metadataTags: input.config.metadataTags, + ssmParameterStoreTags: input.ssmParameterStoreTags, + }); + const commandInput: RunMicrovmCommandInput = { imageIdentifier: input.config.imageIdentifier, imageVersion: input.config.imageVersion, @@ -95,6 +115,9 @@ export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise ({ loadMicrovmProviderConfig: vi.fn() })); vi.mock('./microvms', () => ({ @@ -15,13 +15,14 @@ vi.mock('./microvms', () => ({ })); vi.mock('./runner-metadata', async (importOriginal) => ({ ...(await importOriginal()), - setMicrovmGithubRunnerId: vi.fn(), + setMicrovmGithubRunnerMetadata: vi.fn(), })); const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; const githubClient = {} as Octokit; const createStartRunnerConfig = vi.fn(); +const ssmParameterStoreTags = [{ Key: 'CostCenter', Value: '1234' }]; function runnerConfig(overrides: Partial = {}): CreateGitHubRunnerConfig { return { @@ -35,7 +36,7 @@ function runnerConfig(overrides: Partial = {}): Create disableAutoUpdate: true, ssmTokenPath: '/github-action-runners/unit-test/token', ssmConfigPath: '/github-action-runners/unit-test/config', - ssmParameterStoreTags: [], + ssmParameterStoreTags, ...overrides, }; } @@ -47,9 +48,10 @@ beforeEach(() => { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, + metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], }); vi.mocked(runMicrovmRunner).mockResolvedValue('mvm-1'); - vi.mocked(setMicrovmGithubRunnerId).mockResolvedValue(); + vi.mocked(setMicrovmGithubRunnerMetadata).mockResolvedValue(); vi.mocked(terminateMicrovm).mockResolvedValue(); vi.mocked(isRetryableMicrovmError).mockReturnValue(false); createStartRunnerConfig.mockResolvedValue([]); @@ -93,6 +95,7 @@ describe('createMicrovmRunners', () => { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath: '/github-action-runners/unit-test/token/metadata', + metadataTags: [], }); await expect( @@ -114,7 +117,10 @@ describe('createMicrovmRunners', () => { it('launches each MicroVM and delivers its JIT configuration', async () => { vi.mocked(runMicrovmRunner).mockResolvedValueOnce('mvm-1').mockResolvedValueOnce('mvm-2'); createStartRunnerConfig.mockImplementation(async (_config, runnerIds, _client, options) => { - await options?.onJitConfigCreated?.(runnerIds[0], { githubRunnerId: `github-${runnerIds[0]}`, runnerLabels: [] }); + await options?.onJitConfigCreated?.(runnerIds[0], { + githubRunnerId: `github-${runnerIds[0]}`, + runnerLabels: ['self-hosted', 'microvm'], + }); return []; }); @@ -128,12 +134,16 @@ describe('createMicrovmRunners', () => { runHookPayload: createMicrovmRunHookPayload('/github-action-runners/unit-test/token'), runnerOwner: 'Codertocat', runnerType: 'Org', + ssmParameterStoreTags, source: 'pool-lambda', }); expect(createStartRunnerConfig).toHaveBeenCalledTimes(2); const options = createStartRunnerConfig.mock.calls[0][3]; - expect(options?.getSsmParameterTags?.('mvm-1')).toEqual([{ Key: 'MicrovmId', Value: 'mvm-1' }]); - expect(setMicrovmGithubRunnerId).toHaveBeenNthCalledWith(1, metadataSsmPath, 'mvm-1', 'github-mvm-1'); + expect(options?.getRunnerConfigMetadata?.('mvm-1')).toEqual([{ key: 'MicrovmId', value: 'mvm-1' }]); + expect(setMicrovmGithubRunnerMetadata).toHaveBeenNthCalledWith(1, metadataSsmPath, 'mvm-1', { + githubRunnerId: 'github-mvm-1', + runnerLabels: ['self-hosted', 'microvm'], + }); }); it('applies supported dynamic labels to the provider configuration', async () => { @@ -158,14 +168,19 @@ describe('createMicrovmRunners', () => { imageVersion: '3.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, + metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], }, environment: 'unit-test', runHookPayload: createMicrovmRunHookPayload('/github-action-runners/unit-test/token'), runnerOwner: 'Codertocat', runnerType: 'Org', + ssmParameterStoreTags, source: 'scale-up-lambda', }); - expect(setMicrovmGithubRunnerId).toHaveBeenCalledWith(metadataSsmPath, 'mvm-1', 'github-mvm-1'); + expect(setMicrovmGithubRunnerMetadata).toHaveBeenCalledWith(metadataSsmPath, 'mvm-1', { + githubRunnerId: 'github-mvm-1', + runnerLabels: [], + }); }); it('retries a JIT setup failure even when runner cleanup fails', async () => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts index 5393a49d85..99b7b812b7 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -10,7 +10,7 @@ import type { import type { MicrovmDynamicLabelOverrides } from '../dynamic-labels'; import { loadMicrovmProviderConfig } from './config'; import { isRetryableMicrovmError, runMicrovmRunner, terminateMicrovm } from './microvms'; -import { assertSeparatedMicrovmMetadataPath, setMicrovmGithubRunnerId } from './runner-metadata'; +import { assertSeparatedMicrovmMetadataPath, setMicrovmGithubRunnerMetadata } from './runner-metadata'; const logger = createChildLogger('microvm-runner-config'); @@ -69,13 +69,14 @@ export async function createMicrovmRunners( runHookPayload, runnerOwner: githubRunnerConfig.runnerOwner, runnerType: githubRunnerConfig.runnerType, + ssmParameterStoreTags: githubRunnerConfig.ssmParameterStoreTags, source, }); const failedRunnerIds = await createStartRunnerConfig(githubRunnerConfig, [microvmId], githubInstallationClient, { - getSsmParameterTags: (runnerId) => [{ Key: 'MicrovmId', Value: runnerId }], + getRunnerConfigMetadata: (runnerId) => [{ key: 'MicrovmId', value: runnerId }], onJitConfigCreated: async (runnerId, metadata) => { - await setMicrovmGithubRunnerId(config.metadataSsmPath, runnerId, metadata.githubRunnerId); + await setMicrovmGithubRunnerMetadata(config.metadataSsmPath, runnerId, metadata); }, }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts index de7c37692c..10c0e4df21 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts @@ -1,4 +1,4 @@ -import { deleteParameter, getParametersByPath, putParameter } from '@aws-github-runner/aws-ssm-util'; +import { addParameterTags, deleteParameter, getParametersByPath, putParameter } from '@aws-github-runner/aws-ssm-util'; import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -9,12 +9,13 @@ import { listMicrovmRunnerMetadata, markMicrovmCleanupPending, microvmMetadataParameterName, - setMicrovmGithubRunnerId, + setMicrovmGithubRunnerMetadata, setMicrovmOrphan, type MicrovmRunnerMetadata, } from './runner-metadata'; vi.mock('@aws-github-runner/aws-ssm-util', () => ({ + addParameterTags: vi.fn(), deleteParameter: vi.fn(), getParametersByPath: vi.fn(), putParameter: vi.fn(), @@ -46,6 +47,7 @@ beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); vi.mocked(deleteParameter).mockResolvedValue(); + vi.mocked(addParameterTags).mockResolvedValue(); vi.mocked(getParametersByPath).mockResolvedValue(new Map()); vi.mocked(putParameter).mockResolvedValue(); }); @@ -80,15 +82,71 @@ describe('MicroVM metadata lifecycle', () => { source: 'scale-up-lambda', imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', imageVersion: '3.0', + metadataTags: [ + { Key: 'Name', Value: 'unit-test-runner' }, + { Key: 'ghr:Owner', Value: 'configured-owner-cannot-win' }, + { Key: 'ghr:github_runner_id', Value: 'configured-id-is-not-launch-metadata' }, + { Key: 'ghr:runner_labels', Value: 'configured-labels-are-not-launch-metadata' }, + ], + ssmParameterStoreTags: [ + { Key: 'CostCenter', Value: '1234' }, + { Key: 'Name', Value: 'ssm-name-cannot-win' }, + { Key: 'ghr:created_by', Value: 'configured-source-cannot-win' }, + ], }); expect(putParameter).toHaveBeenCalledWith( `${metadataSsmPath}/mvm-1`, JSON.stringify(metadata({ expiresAt: '2026-08-19T18:05:00.000Z' })), false, + { + tags: [ + { Key: 'CostCenter', Value: '1234' }, + { Key: 'Name', Value: 'unit-test-runner' }, + { Key: 'ghr:created_by', Value: 'scale-up-lambda' }, + { Key: 'ghr:Owner', Value: 'Codertocat' }, + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:environment', Value: 'unit-test' }, + { Key: 'ghr:Type', Value: 'Org' }, + { Key: 'ghr:microvm_id', Value: 'mvm-1' }, + { + Key: 'ghr:microvm_image_arn', + Value: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', + }, + { Key: 'ghr:microvm_image_version', Value: '3.0' }, + ], + }, ); }); + it('rejects reserved tag keys and preserves room for late GitHub metadata', async () => { + const input = { + microvmId: 'mvm-1', + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org' as const, + source: 'scale-up-lambda' as const, + imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', + imageVersion: '3.0', + ssmParameterStoreTags: [], + }; + + await expect( + createMicrovmRunnerMetadata(metadataSsmPath, { + ...input, + metadataTags: [{ Key: 'aws:microvm:image-arn', Value: input.imageArn }], + }), + ).rejects.toThrow('AWS-reserved tag prefix'); + + await expect( + createMicrovmRunnerMetadata(metadataSsmPath, { + ...input, + metadataTags: Array.from({ length: 37 }, (_, index) => ({ Key: `Custom${index}`, Value: 'value' })), + }), + ).rejects.toThrow('cannot have more than 44 launch tags'); + expect(putParameter).not.toHaveBeenCalled(); + }); + it('loads active metadata with independent state and cleans expired inactive records', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); @@ -141,12 +199,71 @@ describe('MicroVM metadata lifecycle', () => { ); }); - it('updates GitHub and orphan state without a shared read-modify-write record', async () => { - await setMicrovmGithubRunnerId(metadataSsmPath, 'mvm-1', 'github-42'); + it('updates GitHub state and adds late GitHub metadata tags to the base parameter', async () => { + const runnerLabels = ['self-hosted', 'linux', 'env:unit-test']; + await setMicrovmGithubRunnerMetadata(metadataSsmPath, 'mvm-1', { + githubRunnerId: 'github-42', + runnerLabels, + }); expect(putParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42', false, { overwrite: true, }); + expect(addParameterTags).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1`, [ + { Key: 'ghr:github_runner_id', Value: 'github-42' }, + { + Key: 'ghr:runner_labels', + Value: `base64url:${Buffer.from(JSON.stringify(runnerLabels), 'utf8').toString('base64url')}`, + }, + ]); + }); + + it('splits encoded runner labels into SSM-safe tag values', async () => { + const runnerLabels = [`label-${'a'.repeat(140)}`, `label-${'b'.repeat(140)}`]; + + await setMicrovmGithubRunnerMetadata(metadataSsmPath, 'mvm-1', { + githubRunnerId: 'github-42', + runnerLabels, + }); + + expect(addParameterTags).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1`, [ + { Key: 'ghr:github_runner_id', Value: 'github-42' }, + { + Key: 'ghr:runner_labels', + Value: `base64url:${Buffer.from(JSON.stringify([runnerLabels[0]]), 'utf8').toString('base64url')}`, + }, + { + Key: 'ghr:runner_labels:2', + Value: `base64url:${Buffer.from(JSON.stringify([runnerLabels[1]]), 'utf8').toString('base64url')}`, + }, + ]); + }); + + it('keeps the GitHub runner ID tag when a runner label is too large', async () => { + await setMicrovmGithubRunnerMetadata(metadataSsmPath, 'mvm-1', { + githubRunnerId: 'github-42', + runnerLabels: ['x'.repeat(300)], + }); + + expect(addParameterTags).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1`, [ + { Key: 'ghr:github_runner_id', Value: 'github-42' }, + ]); + }); + + it('keeps the durable GitHub runner ID when late metadata tagging fails', async () => { + vi.mocked(addParameterTags).mockRejectedValue(new Error('AccessDenied')); + + await expect( + setMicrovmGithubRunnerMetadata(metadataSsmPath, 'mvm-1', { + githubRunnerId: 'github-42', + runnerLabels: [], + }), + ).resolves.toBeUndefined(); + expect(putParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42', false, { + overwrite: true, + }); + }); + it('updates orphan state without a shared read-modify-write record', async () => { await setMicrovmOrphan(metadataSsmPath, 'mvm-1', true); expect(putParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-1.orphan`, 'true', false, { overwrite: true, diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts index f6ef69f390..c1cabff703 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -1,8 +1,9 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { deleteParameter, getParametersByPath, putParameter } from '@aws-github-runner/aws-ssm-util'; +import { addParameterTags, deleteParameter, getParametersByPath, putParameter } from '@aws-github-runner/aws-ssm-util'; import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; -import type { LambdaRunnerSource, RunnerType } from '../../../../core'; +import type { GitHubRunnerMetadata, LambdaRunnerSource, RunnerType } from '../../../../core'; +import type { MicrovmMetadataTag } from './config'; import { MICROVM_LIFETIME_IN_SECONDS } from './lifetime'; const logger = createChildLogger('microvm-runner-metadata'); @@ -10,6 +11,12 @@ const logger = createChildLogger('microvm-runner-metadata'); const METADATA_VERSION = 1; const EXPIRATION_GRACE_IN_SECONDS = 300; const MAX_RECONCILED_RUNNERS = 10; +const MAX_PARAMETER_TAGS = 50; +const MAX_RUNNER_LABEL_TAGS = 5; +const MAX_BASE_PARAMETER_TAGS = MAX_PARAMETER_TAGS - MAX_RUNNER_LABEL_TAGS - 1; +const MAX_TAG_KEY_LENGTH = 128; +const MAX_TAG_VALUE_LENGTH = 256; +const SSM_TAG_VALUE_PATTERN = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u; const MICROVM_ID_PATTERN = /^[A-Za-z0-9_-]+$/; const GITHUB_RUNNER_ID_SUFFIX = '.github-runner-id'; const ORPHAN_SUFFIX = '.orphan'; @@ -41,12 +48,127 @@ export interface CreateMicrovmRunnerMetadataInput { environment: string; imageArn: string; imageVersion?: string; + metadataTags: MicrovmMetadataTag[]; microvmId: string; runnerOwner: string; runnerType: RunnerType; + ssmParameterStoreTags: MicrovmMetadataTag[]; source: LambdaRunnerSource; } +function isProviderOwnedLateTag(key: string): boolean { + return key === 'ghr:github_runner_id' || key === 'ghr:runner_labels' || key.startsWith('ghr:runner_labels:'); +} + +function assertValidParameterTags(tags: MicrovmMetadataTag[]): void { + if (tags.length > MAX_PARAMETER_TAGS) { + throw new Error(`MicroVM metadata cannot have more than ${MAX_PARAMETER_TAGS} tags`); + } + + for (const tag of tags) { + if ( + Array.from(tag.Key).length === 0 || + Array.from(tag.Key).length > MAX_TAG_KEY_LENGTH || + Array.from(tag.Value).length > MAX_TAG_VALUE_LENGTH || + !SSM_TAG_VALUE_PATTERN.test(tag.Key) || + !SSM_TAG_VALUE_PATTERN.test(tag.Value) + ) { + throw new Error(`MicroVM metadata tag '${tag.Key}' does not satisfy SSM tag constraints`); + } + if (tag.Key.toLowerCase().startsWith('aws:')) { + throw new Error(`MicroVM metadata tag '${tag.Key}' uses the AWS-reserved tag prefix`); + } + } +} + +function mergeParameterTags(...tagSets: MicrovmMetadataTag[][]): MicrovmMetadataTag[] { + const tagsByKey = new Map(); + for (const tags of tagSets) { + for (const tag of tags) tagsByKey.set(tag.Key, tag.Value); + } + + return [...tagsByKey].map(([Key, Value]) => ({ Key, Value })); +} + +function createMetadataParameterTags(input: CreateMicrovmRunnerMetadataInput): MicrovmMetadataTag[] { + const configuredTags = mergeParameterTags(input.ssmParameterStoreTags, input.metadataTags).filter( + (tag) => !isProviderOwnedLateTag(tag.Key) && tag.Key !== 'ghr:microvm_image_version', + ); + const providerTags: MicrovmMetadataTag[] = [ + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:created_by', Value: input.source }, + { Key: 'ghr:environment', Value: input.environment }, + { Key: 'ghr:Owner', Value: input.runnerOwner }, + { Key: 'ghr:Type', Value: input.runnerType }, + { Key: 'ghr:microvm_id', Value: input.microvmId }, + { Key: 'ghr:microvm_image_arn', Value: input.imageArn }, + ]; + if (input.imageVersion !== undefined) { + providerTags.push({ Key: 'ghr:microvm_image_version', Value: input.imageVersion }); + } + + const tags = mergeParameterTags(configuredTags, providerTags); + assertValidParameterTags(tags); + if (tags.length > MAX_BASE_PARAMETER_TAGS) { + throw new Error( + `MicroVM metadata cannot have more than ${MAX_BASE_PARAMETER_TAGS} launch tags because ${MAX_RUNNER_LABEL_TAGS + 1} tags are reserved for GitHub runner metadata`, + ); + } + return tags; +} + +export function assertValidMicrovmMetadataTags(input: CreateMicrovmRunnerMetadataInput): void { + createMetadataParameterTags(input); +} + +function encodeRunnerLabelGroups(labels: string[]): string[] { + const encodedGroups: string[] = []; + let group: string[] = []; + const encode = (values: string[]) => `base64url:${Buffer.from(JSON.stringify(values), 'utf8').toString('base64url')}`; + + for (const label of labels) { + const candidate = [...group, label]; + if (Array.from(encode(candidate)).length <= MAX_TAG_VALUE_LENGTH) { + group = candidate; + continue; + } + if (group.length === 0) { + logger.warn('A GitHub runner label was omitted because its encoded value exceeds the SSM tag limit', { + labelLength: Array.from(label).length, + }); + continue; + } + encodedGroups.push(encode(group)); + group = [label]; + if (Array.from(encode(group)).length > MAX_TAG_VALUE_LENGTH) { + logger.warn('A GitHub runner label was omitted because its encoded value exceeds the SSM tag limit', { + labelLength: Array.from(label).length, + }); + group = []; + } + } + if (group.length > 0) encodedGroups.push(encode(group)); + + if (encodedGroups.length > MAX_RUNNER_LABEL_TAGS) { + logger.warn('GitHub runner label SSM tags were truncated to avoid exceeding the metadata tag budget', { + maxRunnerLabelsTagCount: MAX_RUNNER_LABEL_TAGS, + }); + } + return encodedGroups.slice(0, MAX_RUNNER_LABEL_TAGS); +} + +function createGitHubRunnerMetadataTags(metadata: GitHubRunnerMetadata): MicrovmMetadataTag[] { + const tags: MicrovmMetadataTag[] = [{ Key: 'ghr:github_runner_id', Value: metadata.githubRunnerId }]; + tags.push( + ...encodeRunnerLabelGroups(metadata.runnerLabels).map((Value, index) => ({ + Key: index === 0 ? 'ghr:runner_labels' : `ghr:runner_labels:${index + 1}`, + Value, + })), + ); + assertValidParameterTags(tags); + return tags; +} + function normalizedPath(path: string): string { return path.trim().replace(/\/+$/, ''); } @@ -170,7 +292,9 @@ export async function createMicrovmRunnerMetadata( ).toISOString(), }; - await putParameter(microvmMetadataParameterName(metadataSsmPath, input.microvmId), JSON.stringify(metadata), false); + await putParameter(microvmMetadataParameterName(metadataSsmPath, input.microvmId), JSON.stringify(metadata), false, { + tags: createMetadataParameterTags(input), + }); } function invalidStateReason(parameters: Map, baseName: string): string | undefined { @@ -318,15 +442,28 @@ export async function listMicrovmRunnerMetadata( }; } -export async function setMicrovmGithubRunnerId( +export async function setMicrovmGithubRunnerMetadata( metadataSsmPath: string, microvmId: string, - githubRunnerId: string, + metadata: GitHubRunnerMetadata, ): Promise { - if (!githubRunnerId) throw new Error('GitHub runner ID must not be empty'); - await putParameter(stateParameterName(metadataSsmPath, microvmId, GITHUB_RUNNER_ID_SUFFIX), githubRunnerId, false, { - overwrite: true, - }); + if (!metadata.githubRunnerId) throw new Error('GitHub runner ID must not be empty'); + await putParameter( + stateParameterName(metadataSsmPath, microvmId, GITHUB_RUNNER_ID_SUFFIX), + metadata.githubRunnerId, + false, + { + overwrite: true, + }, + ); + try { + await addParameterTags( + microvmMetadataParameterName(metadataSsmPath, microvmId), + createGitHubRunnerMetadataTags(metadata), + ); + } catch (error) { + logger.error(`Failed to tag MicroVM runner '${microvmId}' with GitHub runner metadata`, { error }); + } } export async function setMicrovmOrphan(metadataSsmPath: string, microvmId: string, orphan: boolean): Promise { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts index 02818fb939..9b5df7cd36 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts @@ -19,6 +19,7 @@ const providerConfig = { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, + metadataTags: [], }; beforeEach(() => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts index 58cf080e5e..810e7f3e44 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts @@ -10,6 +10,7 @@ declare global { MICROVM_INGRESS_NETWORK_CONNECTORS: string | undefined; MICROVM_LOG_GROUP: string | undefined; MICROVM_METADATA_SSM_PATH: string; + MICROVM_METADATA_TAGS: string | undefined; } } } From dc70a85483aedd7330a881926ecdaf53b86f6b96 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 21 Aug 2026 14:27:02 +0200 Subject: [PATCH 33/54] feat(microvm): add runner config ARN to hook payload --- .../compute-providers/aws/microvm/README.md | 25 ++++++++------- .../microvm/src/control-plane/config.test.ts | 19 +++++++++++ .../aws/microvm/src/control-plane/config.ts | 14 ++++++++ .../src/control-plane/microvms.test.ts | 1 + .../src/control-plane/runner-config.test.ts | 32 +++++++++++++++---- .../src/control-plane/runner-config.ts | 14 +++++--- .../src/control-plane/runner-metadata.ts | 12 +++---- .../src/control-plane/scale-down.test.ts | 1 + .../aws/microvm/src/environment.d.ts | 1 + 9 files changed, 91 insertions(+), 28 deletions(-) diff --git a/lambdas/libs/compute-providers/aws/microvm/README.md b/lambdas/libs/compute-providers/aws/microvm/README.md index 1f70ec75e4..1cf33df790 100644 --- a/lambdas/libs/compute-providers/aws/microvm/README.md +++ b/lambdas/libs/compute-providers/aws/microvm/README.md @@ -7,11 +7,12 @@ The MicroVM image `/run` hook receives this `runHookPayload`: ```json { "version": 1, - "runnerConfigSsmPath": "/github-action-runners/example/token" + "runnerConfigSsmArn": "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/example/runners/config", + "runnerTokenSsmPath": "/github-action-runners/example/runners/tokens" } ``` -Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and exit its lifecycle entrypoint after the job completes. Trusted control-plane cleanup and the fixed lifetime remain termination backstops. +Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and exit its lifecycle entrypoint after the job completes. `runnerConfigSsmArn` is the configuration ARN prefix; the image reads its own non-secret metadata at `/microvm-metadata/`. Neither identifier contains the JIT configuration value. Trusted control-plane cleanup and the fixed lifetime remain termination backstops. Runner ownership and lifecycle state are stored separately as non-secret `String` parameters under `/`. The immutable base @@ -44,6 +45,7 @@ The control-plane Lambda requires these provider environment variables: - `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) - `MICROVM_METADATA_SSM_PATH` (dedicated SSM path for control-plane metadata) - `MICROVM_METADATA_TAGS` (optional JSON array of base tags for the canonical metadata parameter) +- `MICROVM_RUNNER_CONFIG_SSM_ARN` (runner configuration SSM ARN prefix passed to the image hook) - `MICROVM_LOG_GROUP` (optional) Each runner is launched with a fixed lifetime of 28,800 seconds (8 hours). @@ -61,15 +63,16 @@ ARN. Network connectors also require `lambda:PassNetworkConnector`; because that action does not currently support resource-level permissions, enforce the connector boundary with the explicit dynamic-label allowlist described below. -All MicroVMs using one execution role and JIT prefix share a trust boundary. -Grant that role only `ssm:GetParameter` and `ssm:DeleteParameter` on the -lane-scoped JIT prefix. The image must use the -exact `/` parameter name and must not receive -access to the metadata prefix or path-listing APIs. The `MicrovmId` tag on each -JIT parameter supports operations but is not a documented binding to the -calling MicroVM's session identity. Only allow trusted images and workloads -within a shared role, or isolate trust domains with separate roles, prefixes, -and provider deployments. +All MicroVMs using one execution role, JIT prefix, and metadata prefix share a +trust boundary. Grant that role only `ssm:GetParameter` on +`/microvm-metadata/*`, `ssm:GetParameter` and +`ssm:DeleteParameter` on the lane-scoped JIT prefix, and the runtime log +permissions described above. The image must address its own metadata with its +AWS-provided `microvmId` and must not receive path-listing access. IAM cannot +bind that ID to the calling MicroVM session, so a MicroVM can read other +metadata records in the same lane if it learns their IDs. Only allow trusted +images and workloads within a shared role, or isolate trust domains with +separate roles, prefixes, and provider deployments. ## Dynamic labels diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts index 9646fef794..e3935dfc3f 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts @@ -9,6 +9,8 @@ beforeEach(() => { process.env.MICROVM_IMAGE_ARN = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/microvm-runner'; process.env.MICROVM_METADATA_SSM_PATH = '/github-action-runners/unit-test/microvm-metadata/'; + process.env.MICROVM_RUNNER_CONFIG_SSM_ARN = + 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config'; delete process.env.MICROVM_METADATA_TAGS; delete process.env.MICROVM_IMAGE_VERSION; delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS; @@ -26,6 +28,7 @@ describe('loadMicrovmProviderConfig', () => { egressNetworkConnectors: undefined, metadataSsmPath: '/github-action-runners/unit-test/microvm-metadata', metadataTags: [], + runnerConfigSsmArn: process.env.MICROVM_RUNNER_CONFIG_SSM_ARN, logging: undefined, }); }); @@ -56,6 +59,7 @@ describe('loadMicrovmProviderConfig', () => { ['MICROVM_IMAGE_ARN', 'MICROVM_IMAGE_ARN'], ['MICROVM_EXECUTION_ROLE_ARN', 'MICROVM_EXECUTION_ROLE_ARN'], ['MICROVM_METADATA_SSM_PATH', 'MICROVM_METADATA_SSM_PATH'], + ['MICROVM_RUNNER_CONFIG_SSM_ARN', 'MICROVM_RUNNER_CONFIG_SSM_ARN'], ])('requires %s', (environmentVariable, expectedName) => { delete process.env[environmentVariable]; @@ -81,6 +85,21 @@ describe('loadMicrovmProviderConfig', () => { }, ); + it.each([ + '/github-action-runners/unit-test/config', + 'arn:aws:ssm:eu-west-1:123456789012:parameter', + 'arn:aws:s3:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config', + 'arn:custom:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config', + 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners//config', + 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/../config', + ])('rejects malformed runner configuration SSM ARN %s', (runnerConfigSsmArn) => { + process.env.MICROVM_RUNNER_CONFIG_SSM_ARN = runnerConfigSsmArn; + + expect(() => loadMicrovmProviderConfig()).toThrow( + 'MICROVM_RUNNER_CONFIG_SSM_ARN must be a valid SSM parameter ARN prefix', + ); + }); + it.each([ '[not-json', '{}', diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts index bbf26df264..ae6d65b896 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts @@ -14,6 +14,7 @@ export interface MicrovmProviderConfig { logging?: Logging; metadataSsmPath: string; metadataTags: MicrovmMetadataTag[]; + runnerConfigSsmArn: string; } function requiredEnvironmentValue(name: string, value: string | undefined): string { @@ -37,6 +38,18 @@ function parseMetadataSsmPath(value: string | undefined): string { return path; } +function parseRunnerConfigSsmArn(value: string | undefined): string { + const arn = requiredEnvironmentValue('MICROVM_RUNNER_CONFIG_SSM_ARN', value); + if ( + !/^arn:aws(?:-[a-z0-9-]+)?:ssm:[A-Za-z0-9-]+:\d{12}:parameter\/[A-Za-z0-9_.\-/]+$/.test(arn) || + arn.includes('//') || + arn.split('/').includes('..') + ) { + throw new Error('MICROVM_RUNNER_CONFIG_SSM_ARN must be a valid SSM parameter ARN prefix'); + } + return arn; +} + function parseNetworkConnectors(name: string, value: string | undefined): string[] | undefined { const configuredValue = optionalEnvironmentValue(value); if (!configuredValue) return undefined; @@ -113,6 +126,7 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig { ), metadataSsmPath: parseMetadataSsmPath(process.env.MICROVM_METADATA_SSM_PATH), metadataTags: parseMetadataTags(process.env.MICROVM_METADATA_TAGS), + runnerConfigSsmArn: parseRunnerConfigSsmArn(process.env.MICROVM_RUNNER_CONFIG_SSM_ARN), logging: logGroup ? ({ cloudWatch: { logGroup } } satisfies RunMicrovmCommandInput['logging']) : undefined, }; } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts index b90ee2f092..f9267bf036 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts @@ -42,6 +42,7 @@ const config: MicrovmProviderConfig = { egressNetworkConnectors: ['arn:egress'], metadataSsmPath, metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], + runnerConfigSsmArn: 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config', logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, }; const ssmParameterStoreTags = [{ Key: 'CostCenter', Value: '1234' }]; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts index 278e1dc339..c4a400b41c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts @@ -20,6 +20,7 @@ vi.mock('./runner-metadata', async (importOriginal) => ({ const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; +const runnerConfigSsmArn = 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config'; const githubClient = {} as Octokit; const createStartRunnerConfig = vi.fn(); const ssmParameterStoreTags = [{ Key: 'CostCenter', Value: '1234' }]; @@ -49,6 +50,7 @@ beforeEach(() => { executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], + runnerConfigSsmArn, }); vi.mocked(runMicrovmRunner).mockResolvedValue('mvm-1'); vi.mocked(setMicrovmGithubRunnerMetadata).mockResolvedValue(); @@ -58,10 +60,18 @@ beforeEach(() => { }); describe('createMicrovmRunHookPayload', () => { - it('contains only the versioned SSM prefix contract', () => { - expect(JSON.parse(createMicrovmRunHookPayload('/runner/token'))).toEqual({ + it('contains the versioned runner token path and configuration ARN', () => { + expect( + JSON.parse( + createMicrovmRunHookPayload({ + runnerConfigSsmArn, + runnerTokenSsmPath: '/runner/token', + }), + ), + ).toEqual({ version: 1, - runnerConfigSsmPath: '/runner/token', + runnerConfigSsmArn, + runnerTokenSsmPath: '/runner/token', }); }); }); @@ -88,14 +98,17 @@ describe('createMicrovmRunners', () => { 'scale-up-lambda', ), ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); + + expect(runMicrovmRunner).not.toHaveBeenCalled(); }); - it('rejects a metadata path that overlaps the JIT configuration path', async () => { + it('rejects a metadata path that overlaps the JIT token path', async () => { vi.mocked(loadMicrovmProviderConfig).mockReturnValue({ imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath: '/github-action-runners/unit-test/token/metadata', metadataTags: [], + runnerConfigSsmArn, }); await expect( @@ -131,7 +144,10 @@ describe('createMicrovmRunners', () => { expect(runMicrovmRunner).toHaveBeenNthCalledWith(1, { config: expect.objectContaining({ imageIdentifier: imageArn }), environment: 'unit-test', - runHookPayload: createMicrovmRunHookPayload('/github-action-runners/unit-test/token'), + runHookPayload: createMicrovmRunHookPayload({ + runnerConfigSsmArn, + runnerTokenSsmPath: '/github-action-runners/unit-test/token', + }), runnerOwner: 'Codertocat', runnerType: 'Org', ssmParameterStoreTags, @@ -169,9 +185,13 @@ describe('createMicrovmRunners', () => { executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], + runnerConfigSsmArn, }, environment: 'unit-test', - runHookPayload: createMicrovmRunHookPayload('/github-action-runners/unit-test/token'), + runHookPayload: createMicrovmRunHookPayload({ + runnerConfigSsmArn, + runnerTokenSsmPath: '/github-action-runners/unit-test/token', + }), runnerOwner: 'Codertocat', runnerType: 'Org', ssmParameterStoreTags, diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts index 99b7b812b7..63e28410fe 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -15,14 +15,16 @@ import { assertSeparatedMicrovmMetadataPath, setMicrovmGithubRunnerMetadata } fr const logger = createChildLogger('microvm-runner-config'); export interface MicrovmRunHookPayloadV1 { - runnerConfigSsmPath: string; + runnerConfigSsmArn: string; + runnerTokenSsmPath: string; version: 1; } -export function createMicrovmRunHookPayload(ssmTokenPath: string): string { +export function createMicrovmRunHookPayload(paths: Omit): string { return JSON.stringify({ version: 1, - runnerConfigSsmPath: ssmTokenPath, + runnerConfigSsmArn: paths.runnerConfigSsmArn, + runnerTokenSsmPath: paths.runnerTokenSsmPath, } satisfies MicrovmRunHookPayloadV1); } @@ -43,7 +45,6 @@ export async function createMicrovmRunners( logger.error('Lambda MicroVM runners require SSM_TOKEN_PATH to deliver JIT configuration'); return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; } - let config; try { config = { ...loadMicrovmProviderConfig(), ...overrides }; @@ -58,7 +59,10 @@ export async function createMicrovmRunners( retryableErrorCount: 0, nonRetryableErrorCount: 0, }; - const runHookPayload = createMicrovmRunHookPayload(githubRunnerConfig.ssmTokenPath); + const runHookPayload = createMicrovmRunHookPayload({ + runnerConfigSsmArn: config.runnerConfigSsmArn, + runnerTokenSsmPath: githubRunnerConfig.ssmTokenPath, + }); for (let runnerIndex = 0; runnerIndex < numberOfRunners; runnerIndex++) { let microvmId: string | undefined; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts index c1cabff703..30e8bf7f2b 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -194,15 +194,15 @@ function metadataParameterNames(metadataSsmPath: string, microvmId: string): str ]; } -export function assertSeparatedMicrovmMetadataPath(metadataSsmPath: string, runnerConfigSsmPath: string): void { +export function assertSeparatedMicrovmMetadataPath(metadataSsmPath: string, runnerTokenSsmPath: string): void { const metadataPath = normalizedPath(metadataSsmPath); - const runnerConfigPath = normalizedPath(runnerConfigSsmPath); + const runnerTokenPath = normalizedPath(runnerTokenSsmPath); if ( - metadataPath === runnerConfigPath || - metadataPath.startsWith(`${runnerConfigPath}/`) || - runnerConfigPath.startsWith(`${metadataPath}/`) + metadataPath === runnerTokenPath || + metadataPath.startsWith(`${runnerTokenPath}/`) || + runnerTokenPath.startsWith(`${metadataPath}/`) ) { - throw new Error('MICROVM_METADATA_SSM_PATH must be separate from the runner JIT configuration path'); + throw new Error('MICROVM_METADATA_SSM_PATH must be separate from the runner JIT token path'); } } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts index 9b5df7cd36..eceb259f5e 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts @@ -20,6 +20,7 @@ const providerConfig = { executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, metadataTags: [], + runnerConfigSsmArn: 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config', }; beforeEach(() => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts index 810e7f3e44..5eab57144c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts @@ -11,6 +11,7 @@ declare global { MICROVM_LOG_GROUP: string | undefined; MICROVM_METADATA_SSM_PATH: string; MICROVM_METADATA_TAGS: string | undefined; + MICROVM_RUNNER_CONFIG_SSM_ARN: string; } } } From daa0c8fff1bf946b5f6ac57306624a2af9c61267 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 21 Aug 2026 15:12:03 +0200 Subject: [PATCH 34/54] fix(microvm): reuse runner configuration path --- .../compute-providers/aws/microvm/README.md | 25 ++++----- .../microvm/src/control-plane/config.test.ts | 42 -------------- .../aws/microvm/src/control-plane/config.ts | 56 ------------------- .../src/control-plane/microvms.test.ts | 10 +--- .../aws/microvm/src/control-plane/microvms.ts | 2 - .../src/control-plane/runner-config.test.ts | 50 ++++++++++++----- .../src/control-plane/runner-config.ts | 34 +++++++++-- .../src/control-plane/runner-metadata.test.ts | 28 ++++++---- .../src/control-plane/runner-metadata.ts | 9 ++- .../src/control-plane/scale-down.test.ts | 2 - .../aws/microvm/src/environment.d.ts | 2 - 11 files changed, 98 insertions(+), 162 deletions(-) diff --git a/lambdas/libs/compute-providers/aws/microvm/README.md b/lambdas/libs/compute-providers/aws/microvm/README.md index 1cf33df790..60099dd19c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/README.md +++ b/lambdas/libs/compute-providers/aws/microvm/README.md @@ -7,27 +7,28 @@ The MicroVM image `/run` hook receives this `runHookPayload`: ```json { "version": 1, - "runnerConfigSsmArn": "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/example/runners/config", + "runnerConfigSsmPath": "/github-action-runners/example/runners/config", "runnerTokenSsmPath": "/github-action-runners/example/runners/tokens" } ``` -Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and exit its lifecycle entrypoint after the job completes. `runnerConfigSsmArn` is the configuration ARN prefix; the image reads its own non-secret metadata at `/microvm-metadata/`. Neither identifier contains the JIT configuration value. Trusted control-plane cleanup and the fixed lifetime remain termination backstops. +Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and exit its lifecycle entrypoint after the job completes. The image reads its own non-secret metadata at `/microvm-metadata/`. Neither path contains the JIT configuration value. Trusted control-plane cleanup and the fixed lifetime remain termination backstops. Runner ownership and lifecycle state are stored separately as non-secret `String` parameters under `/`. The immutable base record and independent state parameters prevent concurrent GitHub ID, orphan, and cleanup updates from overwriting one another. Deleting the JIT SecureString does not delete this metadata. Use a dedicated metadata prefix that does not -overlap the JIT path, and do not grant the MicroVM execution role access to it. -The control plane retries pending cleanup, removes metadata after termination, -and reconciles expired records during inventory. +overlap the JIT path, and grant the MicroVM execution role only the exact +value-read access described below, without path-listing permissions. The control +plane retries pending cleanup, removes metadata after termination, and reconciles +expired records during inventory. The immutable base metadata parameter is also the canonical tag surface for a -runner. It merges `SSM_PARAMETER_STORE_TAGS` with the Terraform-generated -`MICROVM_METADATA_TAGS`. Terraform supplies `Name`, `ghr:environment`, -`ghr:ssm_config_path`, and `ghr:runner_name_prefix`; the Lambda then adds -authoritative runtime tags: +runner. It starts with `SSM_PARAMETER_STORE_TAGS`, omits `Name`, and derives +`ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` from +the existing `ENVIRONMENT`, `SSM_CONFIG_PATH`, and `RUNNER_NAME_PREFIX` +settings. The Lambda then adds authoritative runtime tags: `ghr:Application`, `ghr:created_by`, `ghr:environment`, `ghr:Owner`, `ghr:Type`, `ghr:microvm_id`, `ghr:microvm_image_arn`, and, when available, `ghr:microvm_image_version`. After JIT registration, the control plane adds @@ -44,8 +45,6 @@ The control-plane Lambda requires these provider environment variables: - `MICROVM_INGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) - `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) - `MICROVM_METADATA_SSM_PATH` (dedicated SSM path for control-plane metadata) -- `MICROVM_METADATA_TAGS` (optional JSON array of base tags for the canonical metadata parameter) -- `MICROVM_RUNNER_CONFIG_SSM_ARN` (runner configuration SSM ARN prefix passed to the image hook) - `MICROVM_LOG_GROUP` (optional) Each runner is launched with a fixed lifetime of 28,800 seconds (8 hours). @@ -64,8 +63,8 @@ that action does not currently support resource-level permissions, enforce the connector boundary with the explicit dynamic-label allowlist described below. All MicroVMs using one execution role, JIT prefix, and metadata prefix share a -trust boundary. Grant that role only `ssm:GetParameter` on -`/microvm-metadata/*`, `ssm:GetParameter` and +trust boundary. Grant that role only `ssm:GetParameter` on the Parameter Store +ARN corresponding to `/microvm-metadata/*`, `ssm:GetParameter` and `ssm:DeleteParameter` on the lane-scoped JIT prefix, and the runtime log permissions described above. The image must address its own metadata with its AWS-provided `microvmId` and must not receive path-listing access. IAM cannot diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts index e3935dfc3f..b68fdffb7c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts @@ -9,9 +9,6 @@ beforeEach(() => { process.env.MICROVM_IMAGE_ARN = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/microvm-runner'; process.env.MICROVM_METADATA_SSM_PATH = '/github-action-runners/unit-test/microvm-metadata/'; - process.env.MICROVM_RUNNER_CONFIG_SSM_ARN = - 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config'; - delete process.env.MICROVM_METADATA_TAGS; delete process.env.MICROVM_IMAGE_VERSION; delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS; delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS; @@ -27,8 +24,6 @@ describe('loadMicrovmProviderConfig', () => { ingressNetworkConnectors: undefined, egressNetworkConnectors: undefined, metadataSsmPath: '/github-action-runners/unit-test/microvm-metadata', - metadataTags: [], - runnerConfigSsmArn: process.env.MICROVM_RUNNER_CONFIG_SSM_ARN, logging: undefined, }); }); @@ -38,19 +33,11 @@ describe('loadMicrovmProviderConfig', () => { process.env.MICROVM_INGRESS_NETWORK_CONNECTORS = '["arn:ingress:one","arn:ingress:two"]'; process.env.MICROVM_EGRESS_NETWORK_CONNECTORS = 'arn:egress:one, arn:egress:two'; process.env.MICROVM_LOG_GROUP = ' /aws/lambda-microvms/runner '; - process.env.MICROVM_METADATA_TAGS = JSON.stringify([ - { Key: 'Name', Value: 'unit-test-runner' }, - { Key: 'ghr:environment', Value: 'unit-test' }, - ]); expect(loadMicrovmProviderConfig()).toMatchObject({ imageVersion: '3.0', ingressNetworkConnectors: ['arn:ingress:one', 'arn:ingress:two'], egressNetworkConnectors: ['arn:egress:one', 'arn:egress:two'], - metadataTags: [ - { Key: 'Name', Value: 'unit-test-runner' }, - { Key: 'ghr:environment', Value: 'unit-test' }, - ], logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, }); }); @@ -59,7 +46,6 @@ describe('loadMicrovmProviderConfig', () => { ['MICROVM_IMAGE_ARN', 'MICROVM_IMAGE_ARN'], ['MICROVM_EXECUTION_ROLE_ARN', 'MICROVM_EXECUTION_ROLE_ARN'], ['MICROVM_METADATA_SSM_PATH', 'MICROVM_METADATA_SSM_PATH'], - ['MICROVM_RUNNER_CONFIG_SSM_ARN', 'MICROVM_RUNNER_CONFIG_SSM_ARN'], ])('requires %s', (environmentVariable, expectedName) => { delete process.env[environmentVariable]; @@ -84,32 +70,4 @@ describe('loadMicrovmProviderConfig', () => { ); }, ); - - it.each([ - '/github-action-runners/unit-test/config', - 'arn:aws:ssm:eu-west-1:123456789012:parameter', - 'arn:aws:s3:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config', - 'arn:custom:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config', - 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners//config', - 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/../config', - ])('rejects malformed runner configuration SSM ARN %s', (runnerConfigSsmArn) => { - process.env.MICROVM_RUNNER_CONFIG_SSM_ARN = runnerConfigSsmArn; - - expect(() => loadMicrovmProviderConfig()).toThrow( - 'MICROVM_RUNNER_CONFIG_SSM_ARN must be a valid SSM parameter ARN prefix', - ); - }); - - it.each([ - '[not-json', - '{}', - '[{"Key":"Name"}]', - '[{"Key":"","Value":"runner"}]', - '[{"Key":"Name","Value":1}]', - '[{"Key":"Name","Value":"one"},{"Key":"Name","Value":"two"}]', - ])('rejects malformed metadata tags %s', (tags) => { - process.env.MICROVM_METADATA_TAGS = tags; - - expect(() => loadMicrovmProviderConfig()).toThrow(/MICROVM_METADATA_TAGS must/); - }); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts index ae6d65b896..8eacada06c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts @@ -1,10 +1,5 @@ import type { Logging, RunMicrovmCommandInput } from '@aws-sdk/client-lambda-microvms'; -export interface MicrovmMetadataTag { - Key: string; - Value: string; -} - export interface MicrovmProviderConfig { egressNetworkConnectors?: string[]; executionRoleArn: string; @@ -13,8 +8,6 @@ export interface MicrovmProviderConfig { ingressNetworkConnectors?: string[]; logging?: Logging; metadataSsmPath: string; - metadataTags: MicrovmMetadataTag[]; - runnerConfigSsmArn: string; } function requiredEnvironmentValue(name: string, value: string | undefined): string { @@ -38,18 +31,6 @@ function parseMetadataSsmPath(value: string | undefined): string { return path; } -function parseRunnerConfigSsmArn(value: string | undefined): string { - const arn = requiredEnvironmentValue('MICROVM_RUNNER_CONFIG_SSM_ARN', value); - if ( - !/^arn:aws(?:-[a-z0-9-]+)?:ssm:[A-Za-z0-9-]+:\d{12}:parameter\/[A-Za-z0-9_.\-/]+$/.test(arn) || - arn.includes('//') || - arn.split('/').includes('..') - ) { - throw new Error('MICROVM_RUNNER_CONFIG_SSM_ARN must be a valid SSM parameter ARN prefix'); - } - return arn; -} - function parseNetworkConnectors(name: string, value: string | undefined): string[] | undefined { const configuredValue = optionalEnvironmentValue(value); if (!configuredValue) return undefined; @@ -74,41 +55,6 @@ function parseNetworkConnectors(name: string, value: string | undefined): string return connectors.map((connector) => connector.trim()); } -function parseMetadataTags(value: string | undefined): MicrovmMetadataTag[] { - const configuredValue = optionalEnvironmentValue(value); - if (!configuredValue) return []; - - let tags: unknown; - try { - tags = JSON.parse(configuredValue); - } catch (error) { - throw new Error('MICROVM_METADATA_TAGS must be a JSON array of SSM tag objects', { cause: error }); - } - - if ( - !Array.isArray(tags) || - tags.some( - (tag) => - typeof tag !== 'object' || - tag === null || - !('Key' in tag) || - typeof tag.Key !== 'string' || - tag.Key.length === 0 || - !('Value' in tag) || - typeof tag.Value !== 'string', - ) - ) { - throw new Error('MICROVM_METADATA_TAGS must be a JSON array of SSM tag objects'); - } - - const typedTags = tags as MicrovmMetadataTag[]; - if (new Set(typedTags.map((tag) => tag.Key)).size !== typedTags.length) { - throw new Error('MICROVM_METADATA_TAGS must not contain duplicate tag keys'); - } - - return typedTags; -} - export function loadMicrovmProviderConfig(): MicrovmProviderConfig { const logGroup = optionalEnvironmentValue(process.env.MICROVM_LOG_GROUP); @@ -125,8 +71,6 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig { process.env.MICROVM_EGRESS_NETWORK_CONNECTORS, ), metadataSsmPath: parseMetadataSsmPath(process.env.MICROVM_METADATA_SSM_PATH), - metadataTags: parseMetadataTags(process.env.MICROVM_METADATA_TAGS), - runnerConfigSsmArn: parseRunnerConfigSsmArn(process.env.MICROVM_RUNNER_CONFIG_SSM_ARN), logging: logGroup ? ({ cloudWatch: { logGroup } } satisfies RunMicrovmCommandInput['logging']) : undefined, }; } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts index f9267bf036..a661ba09c4 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts @@ -41,8 +41,6 @@ const config: MicrovmProviderConfig = { executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', egressNetworkConnectors: ['arn:egress'], metadataSsmPath, - metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], - runnerConfigSsmArn: 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config', logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, }; const ssmParameterStoreTags = [{ Key: 'CostCenter', Value: '1234' }]; @@ -111,7 +109,6 @@ describe('runMicrovmRunner', () => { source: 'scale-up-lambda', imageArn, imageVersion: '3.1', - metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], ssmParameterStoreTags, }); }); @@ -119,15 +116,12 @@ describe('runMicrovmRunner', () => { it('rejects invalid metadata tags before launching a MicroVM', async () => { await expect( runMicrovmRunner({ - config: { - ...config, - metadataTags: [{ Key: 'aws:microvm:image-arn', Value: imageArn }], - }, + config, environment: 'unit-test', runHookPayload: '{}', runnerOwner: 'Codertocat', runnerType: 'Org', - ssmParameterStoreTags: [], + ssmParameterStoreTags: [{ Key: 'aws:microvm:image-arn', Value: imageArn }], source: 'scale-up-lambda', }), ).rejects.toThrow('AWS-reserved tag prefix'); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index 58151698ff..ecc17fc4ca 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -88,7 +88,6 @@ export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise ({ const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; -const runnerConfigSsmArn = 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config'; +const runnerConfigSsmPath = '/github-action-runners/unit-test/config'; const githubClient = {} as Octokit; const createStartRunnerConfig = vi.fn(); -const ssmParameterStoreTags = [{ Key: 'CostCenter', Value: '1234' }]; +const ssmParameterStoreTags = [ + { Key: 'CostCenter', Value: '1234' }, + { Key: 'Name', Value: 'not-used-for-microvm-metadata' }, + { Key: 'ghr:environment', Value: 'caller-cannot-override' }, + { Key: 'ghr:runner_name_prefix', Value: 'caller-cannot-override' }, + { Key: 'ghr:ssm_config_path', Value: 'caller-cannot-override' }, +]; +const microvmMetadataTags = [ + { Key: 'CostCenter', Value: '1234' }, + { Key: 'ghr:environment', Value: 'unit-test' }, + { Key: 'ghr:runner_name_prefix', Value: 'unit-test-' }, + { Key: 'ghr:ssm_config_path', Value: runnerConfigSsmPath }, +]; function runnerConfig(overrides: Partial = {}): CreateGitHubRunnerConfig { return { @@ -49,8 +61,6 @@ beforeEach(() => { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, - metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], - runnerConfigSsmArn, }); vi.mocked(runMicrovmRunner).mockResolvedValue('mvm-1'); vi.mocked(setMicrovmGithubRunnerMetadata).mockResolvedValue(); @@ -60,17 +70,17 @@ beforeEach(() => { }); describe('createMicrovmRunHookPayload', () => { - it('contains the versioned runner token path and configuration ARN', () => { + it('contains the versioned runner token and configuration paths', () => { expect( JSON.parse( createMicrovmRunHookPayload({ - runnerConfigSsmArn, + runnerConfigSsmPath, runnerTokenSsmPath: '/runner/token', }), ), ).toEqual({ version: 1, - runnerConfigSsmArn, + runnerConfigSsmPath, runnerTokenSsmPath: '/runner/token', }); }); @@ -102,13 +112,25 @@ describe('createMicrovmRunners', () => { expect(runMicrovmRunner).not.toHaveBeenCalled(); }); + it('requires an SSM config path', async () => { + await expect( + createMicrovmRunners( + runnerConfig({ ssmConfigPath: '' }), + 1, + githubClient, + createStartRunnerConfig, + 'scale-up-lambda', + ), + ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); + + expect(runMicrovmRunner).not.toHaveBeenCalled(); + }); + it('rejects a metadata path that overlaps the JIT token path', async () => { vi.mocked(loadMicrovmProviderConfig).mockReturnValue({ imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath: '/github-action-runners/unit-test/token/metadata', - metadataTags: [], - runnerConfigSsmArn, }); await expect( @@ -145,12 +167,12 @@ describe('createMicrovmRunners', () => { config: expect.objectContaining({ imageIdentifier: imageArn }), environment: 'unit-test', runHookPayload: createMicrovmRunHookPayload({ - runnerConfigSsmArn, + runnerConfigSsmPath, runnerTokenSsmPath: '/github-action-runners/unit-test/token', }), runnerOwner: 'Codertocat', runnerType: 'Org', - ssmParameterStoreTags, + ssmParameterStoreTags: microvmMetadataTags, source: 'pool-lambda', }); expect(createStartRunnerConfig).toHaveBeenCalledTimes(2); @@ -184,17 +206,15 @@ describe('createMicrovmRunners', () => { imageVersion: '3.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, - metadataTags: [{ Key: 'Name', Value: 'unit-test-runner' }], - runnerConfigSsmArn, }, environment: 'unit-test', runHookPayload: createMicrovmRunHookPayload({ - runnerConfigSsmArn, + runnerConfigSsmPath, runnerTokenSsmPath: '/github-action-runners/unit-test/token', }), runnerOwner: 'Codertocat', runnerType: 'Org', - ssmParameterStoreTags, + ssmParameterStoreTags: microvmMetadataTags, source: 'scale-up-lambda', }); expect(setMicrovmGithubRunnerMetadata).toHaveBeenCalledWith(metadataSsmPath, 'mvm-1', { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts index 63e28410fe..a7e043a6cb 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -13,9 +13,15 @@ import { isRetryableMicrovmError, runMicrovmRunner, terminateMicrovm } from './m import { assertSeparatedMicrovmMetadataPath, setMicrovmGithubRunnerMetadata } from './runner-metadata'; const logger = createChildLogger('microvm-runner-config'); +const MICROVM_METADATA_CONTEXT_TAG_KEYS = new Set([ + 'Name', + 'ghr:environment', + 'ghr:runner_name_prefix', + 'ghr:ssm_config_path', +]); export interface MicrovmRunHookPayloadV1 { - runnerConfigSsmArn: string; + runnerConfigSsmPath: string; runnerTokenSsmPath: string; version: 1; } @@ -23,11 +29,23 @@ export interface MicrovmRunHookPayloadV1 { export function createMicrovmRunHookPayload(paths: Omit): string { return JSON.stringify({ version: 1, - runnerConfigSsmArn: paths.runnerConfigSsmArn, + runnerConfigSsmPath: paths.runnerConfigSsmPath, runnerTokenSsmPath: paths.runnerTokenSsmPath, } satisfies MicrovmRunHookPayloadV1); } +function createMicrovmMetadataTags( + config: CreateGitHubRunnerConfig, + environment: string, +): CreateGitHubRunnerConfig['ssmParameterStoreTags'] { + return [ + ...config.ssmParameterStoreTags.filter((tag) => !MICROVM_METADATA_CONTEXT_TAG_KEYS.has(tag.Key)), + { Key: 'ghr:environment', Value: environment }, + { Key: 'ghr:runner_name_prefix', Value: config.runnerNamePrefix }, + { Key: 'ghr:ssm_config_path', Value: config.ssmConfigPath }, + ]; +} + export async function createMicrovmRunners( githubRunnerConfig: CreateGitHubRunnerConfig, numberOfRunners: number, @@ -45,6 +63,10 @@ export async function createMicrovmRunners( logger.error('Lambda MicroVM runners require SSM_TOKEN_PATH to deliver JIT configuration'); return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; } + if (!githubRunnerConfig.ssmConfigPath?.trim()) { + logger.error('Lambda MicroVM runners require SSM_CONFIG_PATH to locate runner metadata'); + return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; + } let config; try { config = { ...loadMicrovmProviderConfig(), ...overrides }; @@ -60,20 +82,22 @@ export async function createMicrovmRunners( nonRetryableErrorCount: 0, }; const runHookPayload = createMicrovmRunHookPayload({ - runnerConfigSsmArn: config.runnerConfigSsmArn, + runnerConfigSsmPath: githubRunnerConfig.ssmConfigPath, runnerTokenSsmPath: githubRunnerConfig.ssmTokenPath, }); + const environment = process.env.ENVIRONMENT; + const metadataTags = createMicrovmMetadataTags(githubRunnerConfig, environment); for (let runnerIndex = 0; runnerIndex < numberOfRunners; runnerIndex++) { let microvmId: string | undefined; try { microvmId = await runMicrovmRunner({ config, - environment: process.env.ENVIRONMENT, + environment, runHookPayload, runnerOwner: githubRunnerConfig.runnerOwner, runnerType: githubRunnerConfig.runnerType, - ssmParameterStoreTags: githubRunnerConfig.ssmParameterStoreTags, + ssmParameterStoreTags: metadataTags, source, }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts index 10c0e4df21..cdae1a58a2 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts @@ -82,16 +82,16 @@ describe('MicroVM metadata lifecycle', () => { source: 'scale-up-lambda', imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', imageVersion: '3.0', - metadataTags: [ - { Key: 'Name', Value: 'unit-test-runner' }, - { Key: 'ghr:Owner', Value: 'configured-owner-cannot-win' }, - { Key: 'ghr:github_runner_id', Value: 'configured-id-is-not-launch-metadata' }, - { Key: 'ghr:runner_labels', Value: 'configured-labels-are-not-launch-metadata' }, - ], ssmParameterStoreTags: [ { Key: 'CostCenter', Value: '1234' }, - { Key: 'Name', Value: 'ssm-name-cannot-win' }, + { Key: 'Name', Value: 'not-used-for-microvm-metadata' }, + { Key: 'ghr:Owner', Value: 'configured-owner-cannot-win' }, { Key: 'ghr:created_by', Value: 'configured-source-cannot-win' }, + { Key: 'ghr:environment', Value: 'unit-test' }, + { Key: 'ghr:runner_name_prefix', Value: 'unit-test-' }, + { Key: 'ghr:ssm_config_path', Value: '/github-action-runners/unit-test/config' }, + { Key: 'ghr:github_runner_id', Value: 'configured-id-is-not-launch-metadata' }, + { Key: 'ghr:runner_labels', Value: 'configured-labels-are-not-launch-metadata' }, ], }); @@ -102,11 +102,12 @@ describe('MicroVM metadata lifecycle', () => { { tags: [ { Key: 'CostCenter', Value: '1234' }, - { Key: 'Name', Value: 'unit-test-runner' }, - { Key: 'ghr:created_by', Value: 'scale-up-lambda' }, { Key: 'ghr:Owner', Value: 'Codertocat' }, - { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:created_by', Value: 'scale-up-lambda' }, { Key: 'ghr:environment', Value: 'unit-test' }, + { Key: 'ghr:runner_name_prefix', Value: 'unit-test-' }, + { Key: 'ghr:ssm_config_path', Value: '/github-action-runners/unit-test/config' }, + { Key: 'ghr:Application', Value: 'github-action-runner' }, { Key: 'ghr:Type', Value: 'Org' }, { Key: 'ghr:microvm_id', Value: 'mvm-1' }, { @@ -134,14 +135,17 @@ describe('MicroVM metadata lifecycle', () => { await expect( createMicrovmRunnerMetadata(metadataSsmPath, { ...input, - metadataTags: [{ Key: 'aws:microvm:image-arn', Value: input.imageArn }], + ssmParameterStoreTags: [{ Key: 'aws:microvm:image-arn', Value: input.imageArn }], }), ).rejects.toThrow('AWS-reserved tag prefix'); await expect( createMicrovmRunnerMetadata(metadataSsmPath, { ...input, - metadataTags: Array.from({ length: 37 }, (_, index) => ({ Key: `Custom${index}`, Value: 'value' })), + ssmParameterStoreTags: Array.from({ length: 37 }, (_, index) => ({ + Key: `Custom${index}`, + Value: 'value', + })), }), ).rejects.toThrow('cannot have more than 44 launch tags'); expect(putParameter).not.toHaveBeenCalled(); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts index 30e8bf7f2b..4f479d04ff 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -2,8 +2,7 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { addParameterTags, deleteParameter, getParametersByPath, putParameter } from '@aws-github-runner/aws-ssm-util'; import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; -import type { GitHubRunnerMetadata, LambdaRunnerSource, RunnerType } from '../../../../core'; -import type { MicrovmMetadataTag } from './config'; +import type { CreateGitHubRunnerConfig, GitHubRunnerMetadata, LambdaRunnerSource, RunnerType } from '../../../../core'; import { MICROVM_LIFETIME_IN_SECONDS } from './lifetime'; const logger = createChildLogger('microvm-runner-metadata'); @@ -22,6 +21,7 @@ const GITHUB_RUNNER_ID_SUFFIX = '.github-runner-id'; const ORPHAN_SUFFIX = '.orphan'; const CLEANUP_REQUESTED_AT_SUFFIX = '.cleanup-requested-at'; const ACTIVE_STATES = new Set(['PENDING', 'RUNNING', 'SUSPENDING', 'SUSPENDED']); +type MicrovmMetadataTag = CreateGitHubRunnerConfig['ssmParameterStoreTags'][number]; export interface MicrovmRunnerMetadata { bypassRemoval?: boolean; @@ -48,7 +48,6 @@ export interface CreateMicrovmRunnerMetadataInput { environment: string; imageArn: string; imageVersion?: string; - metadataTags: MicrovmMetadataTag[]; microvmId: string; runnerOwner: string; runnerType: RunnerType; @@ -91,8 +90,8 @@ function mergeParameterTags(...tagSets: MicrovmMetadataTag[][]): MicrovmMetadata } function createMetadataParameterTags(input: CreateMicrovmRunnerMetadataInput): MicrovmMetadataTag[] { - const configuredTags = mergeParameterTags(input.ssmParameterStoreTags, input.metadataTags).filter( - (tag) => !isProviderOwnedLateTag(tag.Key) && tag.Key !== 'ghr:microvm_image_version', + const configuredTags = mergeParameterTags(input.ssmParameterStoreTags).filter( + (tag) => !isProviderOwnedLateTag(tag.Key) && tag.Key !== 'ghr:microvm_image_version' && tag.Key !== 'Name', ); const providerTags: MicrovmMetadataTag[] = [ { Key: 'ghr:Application', Value: 'github-action-runner' }, diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts index eceb259f5e..02818fb939 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts @@ -19,8 +19,6 @@ const providerConfig = { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, - metadataTags: [], - runnerConfigSsmArn: 'arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/unit-test/config', }; beforeEach(() => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts index 5eab57144c..58cf080e5e 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts @@ -10,8 +10,6 @@ declare global { MICROVM_INGRESS_NETWORK_CONNECTORS: string | undefined; MICROVM_LOG_GROUP: string | undefined; MICROVM_METADATA_SSM_PATH: string; - MICROVM_METADATA_TAGS: string | undefined; - MICROVM_RUNNER_CONFIG_SSM_ARN: string; } } } From e4afae68e3bc5d5bc08ddaba57e93bdf801b34e1 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 21 Aug 2026 19:25:18 +0200 Subject: [PATCH 35/54] feat(microvm): extend runner lifecycle metadata --- .../src/scale-runners/github-runner.test.ts | 72 ++++ .../src/scale-runners/github-runner.ts | 9 +- .../compute-providers/aws/microvm/README.md | 51 ++- .../microvm/src/control-plane/config.test.ts | 14 +- .../aws/microvm/src/control-plane/config.ts | 12 +- .../src/control-plane/microvms.test.ts | 66 ++-- .../aws/microvm/src/control-plane/microvms.ts | 47 ++- .../src/control-plane/runner-config.test.ts | 124 +++++-- .../src/control-plane/runner-config.ts | 74 +++- .../src/control-plane/runner-metadata.test.ts | 322 +++++++++++++++--- .../src/control-plane/runner-metadata.ts | 284 ++++++++++----- .../src/control-plane/scale-down.test.ts | 8 +- .../microvm/src/control-plane/scale-down.ts | 10 +- .../aws/microvm/src/environment.d.ts | 1 + 14 files changed, 849 insertions(+), 245 deletions(-) create mode 100644 lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts new file mode 100644 index 0000000000..4f687b1644 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts @@ -0,0 +1,72 @@ +import { putParameter } from '@aws-github-runner/aws-ssm-util'; +import type { Octokit } from '@octokit/rest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createStartRunnerConfig } from './github-runner'; +import type { CreateGitHubRunnerConfig } from './types'; + +vi.mock('@aws-github-runner/aws-ssm-util', () => ({ + getParameter: vi.fn(), + putParameter: vi.fn(), +})); + +const githubRunnerConfig: CreateGitHubRunnerConfig = { + disableAutoUpdate: true, + enableJitConfig: true, + ephemeral: true, + runnerGroup: 'Default', + runnerLabels: 'self-hosted,linux', + runnerNamePrefix: 'runner-', + runnerOwner: 'octocat/runner', + runnerType: 'Repo', + ssmConfigPath: '/github-action-runners/test/config', + ssmParameterStoreTags: [], + ssmTokenPath: '/github-action-runners/test/tokens', +}; + +const generateRunnerJitconfigForRepo = vi.fn(); +const githubClient = { + actions: { generateRunnerJitconfigForRepo }, +} as unknown as Octokit; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(putParameter).mockResolvedValue(); + generateRunnerJitconfigForRepo.mockResolvedValue({ + data: { + encoded_jit_config: 'encoded-jit-config', + runner: { id: 42 }, + }, + headers: {}, + }); +}); + +describe('createStartRunnerConfig', () => { + it('persists JIT configuration before notifying the provider', async () => { + const onJitConfigCreated = vi.fn(async () => { + expect(putParameter).toHaveBeenCalledWith( + '/github-action-runners/test/tokens/microvm-1', + 'encoded-jit-config', + true, + { tags: [] }, + ); + }); + + await expect( + createStartRunnerConfig(githubRunnerConfig, ['microvm-1'], githubClient, { onJitConfigCreated }), + ).resolves.toEqual([]); + expect(onJitConfigCreated).toHaveBeenCalledWith('microvm-1', { + githubRunnerId: '42', + runnerLabels: ['self-hosted', 'linux'], + }); + }); + + it('reports provider post-write fencing failures while leaving cleanup to the provider', async () => { + const onJitConfigCreated = vi.fn().mockRejectedValue(new Error('cleanup already requested')); + + await expect( + createStartRunnerConfig(githubRunnerConfig, ['microvm-1'], githubClient, { onJitConfigCreated }), + ).resolves.toEqual(['microvm-1']); + expect(putParameter).toHaveBeenCalledOnce(); + }); +}); diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index b344012149..47da7cae9a 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -304,11 +304,6 @@ async function createJitConfig( metricGitHubAppRateLimit(runnerConfig.headers, githubRunnerConfig.appIndex); - await options.onJitConfigCreated?.(runnerId, { - githubRunnerId: runnerConfig.data.runner.id.toString(), - runnerLabels, - }); - logger.debug('Runner JIT config for ephemeral runner generated.', { instance: runnerId, }); @@ -316,6 +311,10 @@ async function createJitConfig( { runnerId, value: runnerConfig.data.encoded_jit_config }, { metadata: options.getRunnerConfigMetadata?.(runnerId) }, ); + await options.onJitConfigCreated?.(runnerId, { + githubRunnerId: runnerConfig.data.runner.id.toString(), + runnerLabels, + }); if (isDelay) { // Delay to stay within the selected store's maximum write throughput. await delay(delayMilliseconds); diff --git a/lambdas/libs/compute-providers/aws/microvm/README.md b/lambdas/libs/compute-providers/aws/microvm/README.md index 60099dd19c..bc672dd2a2 100644 --- a/lambdas/libs/compute-providers/aws/microvm/README.md +++ b/lambdas/libs/compute-providers/aws/microvm/README.md @@ -7,12 +7,14 @@ The MicroVM image `/run` hook receives this `runHookPayload`: ```json { "version": 1, + "imageArn": "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner", + "imageVersion": "12.0", "runnerConfigSsmPath": "/github-action-runners/example/runners/config", "runnerTokenSsmPath": "/github-action-runners/example/runners/tokens" } ``` -Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and exit its lifecycle entrypoint after the job completes. The image reads its own non-secret metadata at `/microvm-metadata/`. Neither path contains the JIT configuration value. Trusted control-plane cleanup and the fixed lifetime remain termination backstops. +Lambda adds `microvmId` beside that payload. `imageArn` and `imageVersion` are the requested launch values and are included together when an explicit image version is selected. The image must poll the SecureString parameter at `/`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and exit its lifecycle entrypoint after the job completes. The image separately polls its complete non-secret tag map at `/microvm-metadata/.tags`. The control plane stores the JIT parameter before the provider callback writes the tag map, preventing cleanup from deleting an absent JIT that could otherwise be recreated later. Neither metadata record contains the JIT configuration value. Trusted control-plane cleanup and the fixed lifetime remain termination backstops. Runner ownership and lifecycle state are stored separately as non-secret `String` parameters under `/`. The immutable base @@ -24,8 +26,9 @@ value-read access described below, without path-listing permissions. The control plane retries pending cleanup, removes metadata after termination, and reconciles expired records during inventory. -The immutable base metadata parameter is also the canonical tag surface for a -runner. It starts with `SSM_PARAMETER_STORE_TAGS`, omits `Name`, and derives +The immutable base metadata parameter carries the same AWS resource tags that +are serialized as a JSON object in the `.tags` parameter. The tag +set starts with `SSM_PARAMETER_STORE_TAGS`, omits `Name`, and derives `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` from the existing `ENVIRONMENT`, `SSM_CONFIG_PATH`, and `RUNNER_NAME_PREFIX` settings. The Lambda then adds authoritative runtime tags: @@ -35,7 +38,30 @@ settings. The Lambda then adds authoritative runtime tags: `ghr:github_runner_id` and base64url-encoded runner-label groups under `ghr:runner_labels` through `ghr:runner_labels:5`. Runtime-owned values override configured collisions. The `aws:` tag prefix is reserved and cannot be used for -these SSM parameters. +these SSM parameters. The `.tags` value may use the Parameter Store advanced +tier when its UTF-8 representation is at least 4,000 bytes and is rejected if +the complete value could exceed the 8 KiB Parameter Store limit. + +Final cleanup deletes `/`, the +`.github-runner-id`, `.orphan`, and `.tags` companions, the base ownership +record, and `.cleanup-requested-at` last. The tombstone keeps its original +timestamp through a five-minute grace window so cleanup can repeatedly revoke a +late JIT write before removing every record. Missing parameters are treated as +already cleaned. + +The runner configuration publishes `/enable_cloudwatch` +and, when enabled, `/cloudwatch_agent_config_runner`. +The generated agent configuration reads these image-owned files by default: + +- `/var/log/microvm/internal-services.log` +- `/var/log/microvm/run.log` +- `/opt/actions-runner/_diag/Runner_**.log` + +Their default log-group suffixes are `internal_service`, `run`, and `runner`, +and `{microvm_id}` is an image-expanded log-stream placeholder. The first two +files are part of the MicroVM image contract; the portable lifecycle hook does +not create CloudWatch-specific files. Native RunMicrovm stdout and stderr stay +enabled independently as the early-startup and failure backstop. The control-plane Lambda requires these provider environment variables: @@ -46,12 +72,14 @@ The control-plane Lambda requires these provider environment variables: - `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list) - `MICROVM_METADATA_SSM_PATH` (dedicated SSM path for control-plane metadata) - `MICROVM_LOG_GROUP` (optional) +- `SSM_TOKEN_PATH` (lane-scoped JIT parameter path) Each runner is launched with a fixed lifetime of 28,800 seconds (8 hours). -The control-plane role requires `ssm:GetParametersByPath`, `ssm:PutParameter`, -`ssm:AddTagsToResource`, and `ssm:DeleteParameter` on the dedicated metadata -prefix, plus `lambda:ListMicrovms`, `lambda:RunMicrovm`, and +The control-plane role requires `ssm:GetParametersByPath`, `ssm:GetParameters`, +`ssm:PutParameter`, `ssm:AddTagsToResource`, and `ssm:DeleteParameter` on the +dedicated metadata prefix, plus a separate `ssm:DeleteParameter` grant on the +lane-scoped JIT prefix, and `lambda:ListMicrovms`, `lambda:RunMicrovm`, and `lambda:TerminateMicrovm` for inventory and lifecycle reconciliation. Restrict `lambda:RunMicrovm` and `lambda:TerminateMicrovm` to approved image resources; `lambda:ListMicrovms` does not support resource-level permissions. @@ -64,10 +92,11 @@ connector boundary with the explicit dynamic-label allowlist described below. All MicroVMs using one execution role, JIT prefix, and metadata prefix share a trust boundary. Grant that role only `ssm:GetParameter` on the Parameter Store -ARN corresponding to `/microvm-metadata/*`, `ssm:GetParameter` and -`ssm:DeleteParameter` on the lane-scoped JIT prefix, and the runtime log -permissions described above. The image must address its own metadata with its -AWS-provided `microvmId` and must not receive path-listing access. IAM cannot +ARN corresponding to `/microvm-metadata/*` and the exact +CloudWatch configuration parameters, `ssm:GetParameter` and +`ssm:DeleteParameter` on the lane-scoped JIT prefix, and stream-write access to +the provider-managed log groups. The image must address its own metadata with +its AWS-provided `microvmId` and must not receive path-listing access. IAM cannot bind that ID to the calling MicroVM session, so a MicroVM can read other metadata records in the same lane if it learns their IDs. Only allow trusted images and workloads within a shared role, or isolate trust domains with diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts index b68fdffb7c..e58d73093c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts @@ -9,6 +9,7 @@ beforeEach(() => { process.env.MICROVM_IMAGE_ARN = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/microvm-runner'; process.env.MICROVM_METADATA_SSM_PATH = '/github-action-runners/unit-test/microvm-metadata/'; + process.env.SSM_TOKEN_PATH = '/github-action-runners/unit-test/token/'; delete process.env.MICROVM_IMAGE_VERSION; delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS; delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS; @@ -24,6 +25,7 @@ describe('loadMicrovmProviderConfig', () => { ingressNetworkConnectors: undefined, egressNetworkConnectors: undefined, metadataSsmPath: '/github-action-runners/unit-test/microvm-metadata', + runnerTokenSsmPath: '/github-action-runners/unit-test/token', logging: undefined, }); }); @@ -46,6 +48,7 @@ describe('loadMicrovmProviderConfig', () => { ['MICROVM_IMAGE_ARN', 'MICROVM_IMAGE_ARN'], ['MICROVM_EXECUTION_ROLE_ARN', 'MICROVM_EXECUTION_ROLE_ARN'], ['MICROVM_METADATA_SSM_PATH', 'MICROVM_METADATA_SSM_PATH'], + ['SSM_TOKEN_PATH', 'SSM_TOKEN_PATH'], ])('requires %s', (environmentVariable, expectedName) => { delete process.env[environmentVariable]; @@ -60,7 +63,7 @@ describe('loadMicrovmProviderConfig', () => { expect(() => loadMicrovmProviderConfig()).toThrow(/MICROVM_EGRESS_NETWORK_CONNECTORS must/); }); - it.each(['metadata', '/', '/metadata//nested', '/metadata/has space'])( + it.each(['metadata', '/', '/metadata//nested', '/metadata/../nested', '/metadata/has space'])( 'rejects malformed metadata SSM path %s', (metadataPath) => { process.env.MICROVM_METADATA_SSM_PATH = metadataPath; @@ -70,4 +73,13 @@ describe('loadMicrovmProviderConfig', () => { ); }, ); + + it.each(['token', '/', '/token//nested', '/token/../nested', '/token/has space'])( + 'rejects malformed JIT SSM path %s', + (tokenPath) => { + process.env.SSM_TOKEN_PATH = tokenPath; + + expect(() => loadMicrovmProviderConfig()).toThrow('SSM_TOKEN_PATH must be a valid absolute SSM parameter path'); + }, + ); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts index 8eacada06c..b86331967b 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts @@ -8,6 +8,7 @@ export interface MicrovmProviderConfig { ingressNetworkConnectors?: string[]; logging?: Logging; metadataSsmPath: string; + runnerTokenSsmPath: string; } function requiredEnvironmentValue(name: string, value: string | undefined): string { @@ -23,10 +24,10 @@ function optionalEnvironmentValue(value: string | undefined): string | undefined return trimmed ? trimmed : undefined; } -function parseMetadataSsmPath(value: string | undefined): string { - const path = requiredEnvironmentValue('MICROVM_METADATA_SSM_PATH', value).replace(/\/+$/, ''); - if (path === '' || !/^\/[A-Za-z0-9_.\-/]+$/.test(path) || path.includes('//')) { - throw new Error('MICROVM_METADATA_SSM_PATH must be a valid absolute SSM parameter path'); +function parseSsmPath(name: string, value: string | undefined): string { + const path = requiredEnvironmentValue(name, value).replace(/\/+$/, ''); + if (path === '' || !/^\/[A-Za-z0-9_.\-/]+$/.test(path) || path.includes('//') || path.split('/').includes('..')) { + throw new Error(`${name} must be a valid absolute SSM parameter path`); } return path; } @@ -70,7 +71,8 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig { 'MICROVM_EGRESS_NETWORK_CONNECTORS', process.env.MICROVM_EGRESS_NETWORK_CONNECTORS, ), - metadataSsmPath: parseMetadataSsmPath(process.env.MICROVM_METADATA_SSM_PATH), + metadataSsmPath: parseSsmPath('MICROVM_METADATA_SSM_PATH', process.env.MICROVM_METADATA_SSM_PATH), + runnerTokenSsmPath: parseSsmPath('SSM_TOKEN_PATH', process.env.SSM_TOKEN_PATH), logging: logGroup ? ({ cloudWatch: { logGroup } } satisfies RunMicrovmCommandInput['logging']) : undefined, }; } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts index a661ba09c4..3271fd8b98 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts @@ -18,7 +18,7 @@ import { } from './microvms'; import { createMicrovmRunnerMetadata, - deleteMicrovmRunnerMetadata, + deleteMicrovmRunnerJitConfig, listMicrovmRunnerMetadata, markMicrovmCleanupPending, type MicrovmRunnerMetadata, @@ -27,7 +27,7 @@ import { vi.mock('./runner-metadata', async (importOriginal) => ({ ...(await importOriginal()), createMicrovmRunnerMetadata: vi.fn(), - deleteMicrovmRunnerMetadata: vi.fn(), + deleteMicrovmRunnerJitConfig: vi.fn(), listMicrovmRunnerMetadata: vi.fn(), markMicrovmCleanupPending: vi.fn(), })); @@ -35,12 +35,15 @@ vi.mock('./runner-metadata', async (importOriginal) => ({ const mockMicrovmClient = mockClient(LambdaMicrovmsClient); const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; +const runnerTokenSsmPath = '/github-action-runners/unit-test/token'; +const ssmPaths = { metadataSsmPath, runnerTokenSsmPath }; const config: MicrovmProviderConfig = { imageIdentifier: imageArn, imageVersion: '3.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', egressNetworkConnectors: ['arn:egress'], metadataSsmPath, + runnerTokenSsmPath, logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } }, }; const ssmParameterStoreTags = [{ Key: 'CostCenter', Value: '1234' }]; @@ -68,8 +71,8 @@ beforeEach(() => { delete process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS; process.env.AWS_REGION = 'eu-west-1'; process.env.RUNNER_BOOT_TIME_IN_MINUTES = '5'; - vi.mocked(createMicrovmRunnerMetadata).mockResolvedValue(); - vi.mocked(deleteMicrovmRunnerMetadata).mockResolvedValue(); + vi.mocked(createMicrovmRunnerMetadata).mockResolvedValue(ssmParameterStoreTags); + vi.mocked(deleteMicrovmRunnerJitConfig).mockResolvedValue(); vi.mocked(listMicrovmRunnerMetadata).mockResolvedValue({ cleanupMicrovmIds: [], metadataById: new Map() }); vi.mocked(markMicrovmCleanupPending).mockResolvedValue(); }); @@ -89,7 +92,7 @@ describe('runMicrovmRunner', () => { ssmParameterStoreTags, source: 'scale-up-lambda', }), - ).resolves.toBe('mvm-123'); + ).resolves.toEqual({ microvmId: 'mvm-123', metadataTags: ssmParameterStoreTags }); expect(mockMicrovmClient).toHaveReceivedCommandWith(RunMicrovmCommand, { imageIdentifier: imageArn, @@ -216,7 +219,7 @@ describe('listMicrovmRunners', () => { runnerOwner: 'Codertocat', runnerType: 'Org', }, - metadataSsmPath, + ssmPaths, ), ).resolves.toEqual([ { @@ -237,7 +240,7 @@ describe('listMicrovmRunners', () => { nextToken: 'page-2', }); expect(listMicrovmRunnerMetadata).toHaveBeenCalledWith( - metadataSsmPath, + ssmPaths, new Map([ ['mvm-managed', 'RUNNING'], ['mvm-terminated', 'TERMINATED'], @@ -268,10 +271,10 @@ describe('listMicrovmRunners', () => { ]), }); - await expect(listMicrovmRunners({ environment: 'unit-test' }, metadataSsmPath)).resolves.toEqual([]); - await expect(listMicrovmRunners({ runnerOwner: 'Codertocat' }, metadataSsmPath)).resolves.toEqual([]); - await expect(listMicrovmRunners({ runnerType: 'Org' }, metadataSsmPath)).resolves.toEqual([]); - await expect(listMicrovmRunners({ orphan: true }, metadataSsmPath)).resolves.toEqual([]); + await expect(listMicrovmRunners({ environment: 'unit-test' }, ssmPaths)).resolves.toEqual([]); + await expect(listMicrovmRunners({ runnerOwner: 'Codertocat' }, ssmPaths)).resolves.toEqual([]); + await expect(listMicrovmRunners({ runnerType: 'Org' }, ssmPaths)).resolves.toEqual([]); + await expect(listMicrovmRunners({ orphan: true }, ssmPaths)).resolves.toEqual([]); }); it('fails closed for an image mismatch while ignoring unowned MicroVMs', async () => { @@ -288,7 +291,7 @@ describe('listMicrovmRunners', () => { ]), }); - await expect(listMicrovmRunners({}, metadataSsmPath)).rejects.toThrow('does not match its metadata'); + await expect(listMicrovmRunners({}, ssmPaths)).rejects.toThrow('does not match its metadata'); }); it('attempts every pending cleanup and fails inventory closed when a retry fails', async () => { @@ -306,7 +309,7 @@ describe('listMicrovmRunners', () => { metadataById: new Map(), }); - await expect(listMicrovmRunners({}, metadataSsmPath)).rejects.toThrow('cleanup failed'); + await expect(listMicrovmRunners({}, ssmPaths)).rejects.toThrow('cleanup failed'); expect(mockMicrovmClient).toHaveReceivedCommandWith(TerminateMicrovmCommand, { microvmIdentifier: 'mvm-first', }); @@ -322,7 +325,7 @@ describe('listMicrovmRunners', () => { }); vi.mocked(listMicrovmRunnerMetadata).mockRejectedValue(new Error('AccessDenied')); - await expect(listMicrovmRunners({}, metadataSsmPath)).rejects.toThrow('AccessDenied'); + await expect(listMicrovmRunners({}, ssmPaths)).rejects.toThrow('AccessDenied'); }); }); @@ -330,26 +333,47 @@ describe('MicroVM lifecycle helpers', () => { it('retains metadata until inventory observes a terminated MicroVM', async () => { mockMicrovmClient.on(TerminateMicrovmCommand).resolves({}); - await terminateMicrovm('mvm-123', metadataSsmPath); + await terminateMicrovm('mvm-123', ssmPaths); + expect(deleteMicrovmRunnerJitConfig).toHaveBeenCalledWith(runnerTokenSsmPath, 'mvm-123'); expect(markMicrovmCleanupPending).toHaveBeenCalledWith(metadataSsmPath, 'mvm-123'); - expect(deleteMicrovmRunnerMetadata).not.toHaveBeenCalled(); }); - it('treats an already terminated MicroVM as successful cleanup', async () => { + it('retains the tombstone when the MicroVM is already terminated so a late JIT write can be revoked', async () => { const notFound = Object.assign(new Error('gone'), { name: 'ResourceNotFoundException' }); mockMicrovmClient.on(TerminateMicrovmCommand).rejects(notFound); - await expect(terminateMicrovm('mvm-gone', metadataSsmPath)).resolves.toBeUndefined(); - expect(deleteMicrovmRunnerMetadata).toHaveBeenCalledWith(metadataSsmPath, 'mvm-gone'); + await expect(terminateMicrovm('mvm-gone', ssmPaths)).resolves.toBeUndefined(); + expect(markMicrovmCleanupPending).toHaveBeenCalledWith(metadataSsmPath, 'mvm-gone'); + expect(deleteMicrovmRunnerJitConfig).toHaveBeenCalledWith(runnerTokenSsmPath, 'mvm-gone'); }); it('retains metadata and marks cleanup pending when termination fails', async () => { mockMicrovmClient.on(TerminateMicrovmCommand).rejects(new Error('terminate failed')); - await expect(terminateMicrovm('mvm-123', metadataSsmPath)).rejects.toThrow('terminate failed'); + await expect(terminateMicrovm('mvm-123', ssmPaths)).rejects.toThrow('terminate failed'); expect(markMicrovmCleanupPending).toHaveBeenCalledWith(metadataSsmPath, 'mvm-123'); - expect(deleteMicrovmRunnerMetadata).not.toHaveBeenCalled(); + }); + + it('retains the cleanup marker and reports a JIT deletion failure after termination succeeds', async () => { + const error = new Error('JIT cleanup failed'); + vi.mocked(deleteMicrovmRunnerJitConfig).mockRejectedValue(error); + mockMicrovmClient.on(TerminateMicrovmCommand).resolves({}); + + await expect(terminateMicrovm('mvm-123', ssmPaths)).rejects.toBe(error); + expect(markMicrovmCleanupPending).toHaveBeenCalledWith(metadataSsmPath, 'mvm-123'); + }); + + it('still terminates and reports a cleanup-marker failure for retry', async () => { + const error = new Error('metadata cleanup marker failed'); + vi.mocked(markMicrovmCleanupPending).mockRejectedValue(error); + mockMicrovmClient.on(TerminateMicrovmCommand).resolves({}); + + await expect(terminateMicrovm('mvm-123', ssmPaths)).rejects.toBe(error); + expect(deleteMicrovmRunnerJitConfig).toHaveBeenCalledWith(runnerTokenSsmPath, 'mvm-123'); + expect(mockMicrovmClient).toHaveReceivedCommandWith(TerminateMicrovmCommand, { + microvmIdentifier: 'mvm-123', + }); }); it('evaluates the configured boot window', () => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index ecc17fc4ca..5ffd6d74b5 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -21,9 +21,10 @@ import { MICROVM_LIFETIME_IN_SECONDS } from './lifetime'; import { assertValidMicrovmMetadataTags, createMicrovmRunnerMetadata, - deleteMicrovmRunnerMetadata, + deleteMicrovmRunnerJitConfig, listMicrovmRunnerMetadata, markMicrovmCleanupPending, + type MicrovmSsmPaths, } from './runner-metadata'; const logger = createChildLogger('microvm-runners'); @@ -45,6 +46,11 @@ export interface RunMicrovmRunnerInput { source: LambdaRunnerSource; } +export interface RunMicrovmRunnerResult { + metadataTags: CreateGitHubRunnerConfig['ssmParameterStoreTags']; + microvmId: string; +} + interface AwsErrorLike extends Error { cause?: unknown; code?: string; @@ -79,7 +85,7 @@ function microvmClient(): LambdaMicrovmsClient { return getTracedAWSV3Client(new LambdaMicrovmsClient({ region: process.env.AWS_REGION })); } -export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { +export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { assertValidMicrovmMetadataTags({ microvmId: 'microvm-validation', environment: input.environment, @@ -118,7 +124,7 @@ export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { + await terminateMicrovm(response.microvmId, input.config).catch((terminationError) => { logger.error(`Failed to terminate untracked MicroVM runner '${response.microvmId}'`, { error: terminationError, }); }); throw error; } - - return response.microvmId; } export async function listMicrovmRunners( filters: ListRunnerFilters = {}, - metadataSsmPath = loadMicrovmProviderConfig().metadataSsmPath, + paths: MicrovmSsmPaths = loadMicrovmProviderConfig(), ): Promise { const client = microvmClient(); const items: MicrovmItem[] = []; @@ -169,13 +174,13 @@ export async function listMicrovmRunners( const microvmStates = new Map( items.flatMap((item) => (item.microvmId && item.state ? [[item.microvmId, item.state] as const] : [])), ); - const { cleanupMicrovmIds, metadataById } = await listMicrovmRunnerMetadata(metadataSsmPath, microvmStates); + const { cleanupMicrovmIds, metadataById } = await listMicrovmRunnerMetadata(paths, microvmStates); let cleanupError: unknown; for (const microvmId of cleanupMicrovmIds) { logger.warn(`Retrying cleanup of MicroVM runner '${microvmId}'`); try { - await terminateMicrovm(microvmId, metadataSsmPath); + await terminateMicrovm(microvmId, paths); } catch (error) { cleanupError ??= error; logger.error(`Failed to retry cleanup of MicroVM runner '${microvmId}'`, { error }); @@ -213,22 +218,34 @@ export async function listMicrovmRunners( return runners; } -export async function terminateMicrovm(microvmId: string, metadataSsmPath: string): Promise { +export async function terminateMicrovm(microvmId: string, paths: MicrovmSsmPaths): Promise { + let cleanupPreparationError: unknown; + try { + await markMicrovmCleanupPending(paths.metadataSsmPath, microvmId); + } catch (error) { + cleanupPreparationError = error; + logger.error(`Failed to mark MicroVM runner '${microvmId}' for cleanup`, { error }); + } + + try { + await deleteMicrovmRunnerJitConfig(paths.runnerTokenSsmPath, microvmId); + } catch (error) { + cleanupPreparationError ??= error; + logger.error(`Failed to delete JIT configuration for MicroVM runner '${microvmId}'`, { error }); + } + try { await microvmClient().send(new TerminateMicrovmCommand({ microvmIdentifier: microvmId })); } catch (error) { if (error instanceof Error && error.name === 'ResourceNotFoundException') { - await deleteMicrovmRunnerMetadata(metadataSsmPath, microvmId); + if (cleanupPreparationError !== undefined) throw cleanupPreparationError; return; } - await markMicrovmCleanupPending(metadataSsmPath, microvmId).catch((metadataError) => { - logger.error(`Failed to mark MicroVM runner '${microvmId}' for cleanup`, { error: metadataError }); - }); throw error; } - await markMicrovmCleanupPending(metadataSsmPath, microvmId); + if (cleanupPreparationError !== undefined) throw cleanupPreparationError; } export function microvmBootTimeExceeded(runner: { launchTime?: Date }): boolean { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts index f645dc9462..ce7262049e 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts @@ -21,6 +21,7 @@ vi.mock('./runner-metadata', async (importOriginal) => ({ const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; const runnerConfigSsmPath = '/github-action-runners/unit-test/config'; +const runnerTokenSsmPath = '/github-action-runners/unit-test/token'; const githubClient = {} as Octokit; const createStartRunnerConfig = vi.fn(); const ssmParameterStoreTags = [ @@ -36,6 +37,18 @@ const microvmMetadataTags = [ { Key: 'ghr:runner_name_prefix', Value: 'unit-test-' }, { Key: 'ghr:ssm_config_path', Value: runnerConfigSsmPath }, ]; +const canonicalMetadataTags = [ + ...microvmMetadataTags, + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:microvm_id', Value: 'mvm-1' }, +]; +const providerConfig = { + imageIdentifier: imageArn, + imageVersion: '2.0', + executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', + metadataSsmPath, + runnerTokenSsmPath, +}; function runnerConfig(overrides: Partial = {}): CreateGitHubRunnerConfig { return { @@ -47,7 +60,7 @@ function runnerConfig(overrides: Partial = {}): Create runnerOwner: 'Codertocat', runnerType: 'Org', disableAutoUpdate: true, - ssmTokenPath: '/github-action-runners/unit-test/token', + ssmTokenPath: runnerTokenSsmPath, ssmConfigPath: '/github-action-runners/unit-test/config', ssmParameterStoreTags, ...overrides, @@ -57,12 +70,8 @@ function runnerConfig(overrides: Partial = {}): Create beforeEach(() => { vi.clearAllMocks(); process.env.ENVIRONMENT = 'unit-test'; - vi.mocked(loadMicrovmProviderConfig).mockReturnValue({ - imageIdentifier: imageArn, - executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', - metadataSsmPath, - }); - vi.mocked(runMicrovmRunner).mockResolvedValue('mvm-1'); + vi.mocked(loadMicrovmProviderConfig).mockReturnValue(providerConfig); + vi.mocked(runMicrovmRunner).mockResolvedValue({ microvmId: 'mvm-1', metadataTags: canonicalMetadataTags }); vi.mocked(setMicrovmGithubRunnerMetadata).mockResolvedValue(); vi.mocked(terminateMicrovm).mockResolvedValue(); vi.mocked(isRetryableMicrovmError).mockReturnValue(false); @@ -70,20 +79,42 @@ beforeEach(() => { }); describe('createMicrovmRunHookPayload', () => { - it('contains the versioned runner token and configuration paths', () => { + it('contains the image and versioned runner paths', () => { expect( JSON.parse( createMicrovmRunHookPayload({ + imageArn, + imageVersion: '2.0', runnerConfigSsmPath, runnerTokenSsmPath: '/runner/token', }), ), ).toEqual({ + imageArn, + imageVersion: '2.0', version: 1, runnerConfigSsmPath, runnerTokenSsmPath: '/runner/token', }); }); + + it('requires the image ARN and version to be provided together', () => { + expect(() => + createMicrovmRunHookPayload({ + imageArn, + runnerConfigSsmPath, + runnerTokenSsmPath, + }), + ).toThrow('MicroVM hook payload image ARN and version must be provided together'); + }); + + it('omits image metadata when no explicit image version is selected', () => { + expect(JSON.parse(createMicrovmRunHookPayload({ runnerConfigSsmPath, runnerTokenSsmPath }))).toEqual({ + version: 1, + runnerConfigSsmPath, + runnerTokenSsmPath, + }); + }); }); describe('createMicrovmRunners', () => { @@ -131,6 +162,7 @@ describe('createMicrovmRunners', () => { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath: '/github-action-runners/unit-test/token/metadata', + runnerTokenSsmPath, }); await expect( @@ -139,6 +171,36 @@ describe('createMicrovmRunners', () => { expect(runMicrovmRunner).not.toHaveBeenCalled(); }); + it('canonicalizes the configuration and token paths before launching or writing JIT configuration', async () => { + await expect( + createMicrovmRunners( + runnerConfig({ ssmConfigPath: `${runnerConfigSsmPath}/`, ssmTokenPath: `${runnerTokenSsmPath}/` }), + 1, + githubClient, + createStartRunnerConfig, + 'scale-up-lambda', + ), + ).resolves.toEqual({ instances: ['mvm-1'], retryableErrorCount: 0, nonRetryableErrorCount: 0 }); + + expect(runMicrovmRunner).toHaveBeenCalledWith( + expect.objectContaining({ + runHookPayload: createMicrovmRunHookPayload({ + imageArn, + imageVersion: '2.0', + runnerConfigSsmPath, + runnerTokenSsmPath, + }), + ssmParameterStoreTags: microvmMetadataTags, + }), + ); + expect(createStartRunnerConfig).toHaveBeenCalledWith( + expect.objectContaining({ ssmConfigPath: runnerConfigSsmPath, ssmTokenPath: runnerTokenSsmPath }), + ['mvm-1'], + githubClient, + expect.any(Object), + ); + }); + it('classifies invalid provider configuration as non-retryable', async () => { vi.mocked(loadMicrovmProviderConfig).mockImplementation(() => { throw new Error('missing image'); @@ -150,7 +212,9 @@ describe('createMicrovmRunners', () => { }); it('launches each MicroVM and delivers its JIT configuration', async () => { - vi.mocked(runMicrovmRunner).mockResolvedValueOnce('mvm-1').mockResolvedValueOnce('mvm-2'); + vi.mocked(runMicrovmRunner) + .mockResolvedValueOnce({ microvmId: 'mvm-1', metadataTags: canonicalMetadataTags }) + .mockResolvedValueOnce({ microvmId: 'mvm-2', metadataTags: canonicalMetadataTags }); createStartRunnerConfig.mockImplementation(async (_config, runnerIds, _client, options) => { await options?.onJitConfigCreated?.(runnerIds[0], { githubRunnerId: `github-${runnerIds[0]}`, @@ -167,8 +231,10 @@ describe('createMicrovmRunners', () => { config: expect.objectContaining({ imageIdentifier: imageArn }), environment: 'unit-test', runHookPayload: createMicrovmRunHookPayload({ + imageArn, + imageVersion: '2.0', runnerConfigSsmPath, - runnerTokenSsmPath: '/github-action-runners/unit-test/token', + runnerTokenSsmPath, }), runnerOwner: 'Codertocat', runnerType: 'Org', @@ -178,10 +244,16 @@ describe('createMicrovmRunners', () => { expect(createStartRunnerConfig).toHaveBeenCalledTimes(2); const options = createStartRunnerConfig.mock.calls[0][3]; expect(options?.getRunnerConfigMetadata?.('mvm-1')).toEqual([{ key: 'MicrovmId', value: 'mvm-1' }]); - expect(setMicrovmGithubRunnerMetadata).toHaveBeenNthCalledWith(1, metadataSsmPath, 'mvm-1', { - githubRunnerId: 'github-mvm-1', - runnerLabels: ['self-hosted', 'microvm'], - }); + expect(setMicrovmGithubRunnerMetadata).toHaveBeenNthCalledWith( + 1, + providerConfig, + 'mvm-1', + { + githubRunnerId: 'github-mvm-1', + runnerLabels: ['self-hosted', 'microvm'], + }, + canonicalMetadataTags, + ); }); it('applies supported dynamic labels to the provider configuration', async () => { @@ -206,21 +278,31 @@ describe('createMicrovmRunners', () => { imageVersion: '3.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, + runnerTokenSsmPath, }, environment: 'unit-test', runHookPayload: createMicrovmRunHookPayload({ + imageArn: overrideImageArn, + imageVersion: '3.0', runnerConfigSsmPath, - runnerTokenSsmPath: '/github-action-runners/unit-test/token', + runnerTokenSsmPath, }), runnerOwner: 'Codertocat', runnerType: 'Org', ssmParameterStoreTags: microvmMetadataTags, source: 'scale-up-lambda', }); - expect(setMicrovmGithubRunnerMetadata).toHaveBeenCalledWith(metadataSsmPath, 'mvm-1', { - githubRunnerId: 'github-mvm-1', - runnerLabels: [], - }); + expect(setMicrovmGithubRunnerMetadata).toHaveBeenCalledWith( + { + ...providerConfig, + egressNetworkConnectors: [overrideEgressConnectorArn], + imageIdentifier: overrideImageArn, + imageVersion: '3.0', + }, + 'mvm-1', + { githubRunnerId: 'github-mvm-1', runnerLabels: [] }, + canonicalMetadataTags, + ); }); it('retries a JIT setup failure even when runner cleanup fails', async () => { @@ -231,7 +313,7 @@ describe('createMicrovmRunners', () => { createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), ).resolves.toEqual({ instances: [], retryableErrorCount: 1, nonRetryableErrorCount: 0 }); - expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', metadataSsmPath); + expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', providerConfig); }); it.each([ @@ -254,6 +336,6 @@ describe('createMicrovmRunners', () => { createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); - expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', metadataSsmPath); + expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', providerConfig); }); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts index a7e043a6cb..251c15dfe7 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -10,7 +10,12 @@ import type { import type { MicrovmDynamicLabelOverrides } from '../dynamic-labels'; import { loadMicrovmProviderConfig } from './config'; import { isRetryableMicrovmError, runMicrovmRunner, terminateMicrovm } from './microvms'; -import { assertSeparatedMicrovmMetadataPath, setMicrovmGithubRunnerMetadata } from './runner-metadata'; +import { + assertMatchingMicrovmRunnerTokenPath, + assertSeparatedMicrovmMetadataPath, + normalizeMicrovmSsmPath, + setMicrovmGithubRunnerMetadata, +} from './runner-metadata'; const logger = createChildLogger('microvm-runner-config'); const MICROVM_METADATA_CONTEXT_TAG_KEYS = new Set([ @@ -21,16 +26,30 @@ const MICROVM_METADATA_CONTEXT_TAG_KEYS = new Set([ ]); export interface MicrovmRunHookPayloadV1 { + imageArn?: string; + imageVersion?: string; runnerConfigSsmPath: string; runnerTokenSsmPath: string; version: 1; } -export function createMicrovmRunHookPayload(paths: Omit): string { +export function createMicrovmRunHookPayload(payload: Omit): string { + const hasImageArn = payload.imageArn !== undefined; + const hasImageVersion = payload.imageVersion !== undefined; + if (hasImageArn !== hasImageVersion) { + throw new Error('MicroVM hook payload image ARN and version must be provided together'); + } + return JSON.stringify({ version: 1, - runnerConfigSsmPath: paths.runnerConfigSsmPath, - runnerTokenSsmPath: paths.runnerTokenSsmPath, + ...(hasImageArn + ? { + imageArn: payload.imageArn, + imageVersion: payload.imageVersion, + } + : {}), + runnerConfigSsmPath: payload.runnerConfigSsmPath, + runnerTokenSsmPath: payload.runnerTokenSsmPath, } satisfies MicrovmRunHookPayloadV1); } @@ -68,9 +87,16 @@ export async function createMicrovmRunners( return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; } let config; + let normalizedGithubRunnerConfig: CreateGitHubRunnerConfig; try { config = { ...loadMicrovmProviderConfig(), ...overrides }; - assertSeparatedMicrovmMetadataPath(config.metadataSsmPath, githubRunnerConfig.ssmTokenPath); + assertMatchingMicrovmRunnerTokenPath(config.runnerTokenSsmPath, githubRunnerConfig.ssmTokenPath); + assertSeparatedMicrovmMetadataPath(config.metadataSsmPath, config.runnerTokenSsmPath); + normalizedGithubRunnerConfig = { + ...githubRunnerConfig, + ssmConfigPath: normalizeMicrovmSsmPath(githubRunnerConfig.ssmConfigPath), + ssmTokenPath: config.runnerTokenSsmPath, + }; } catch (error) { logger.error('Invalid Lambda MicroVM provider configuration', { error }); return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; @@ -82,34 +108,46 @@ export async function createMicrovmRunners( nonRetryableErrorCount: 0, }; const runHookPayload = createMicrovmRunHookPayload({ - runnerConfigSsmPath: githubRunnerConfig.ssmConfigPath, - runnerTokenSsmPath: githubRunnerConfig.ssmTokenPath, + ...(config.imageVersion !== undefined + ? { + imageArn: config.imageIdentifier, + imageVersion: config.imageVersion, + } + : {}), + runnerConfigSsmPath: normalizedGithubRunnerConfig.ssmConfigPath, + runnerTokenSsmPath: normalizedGithubRunnerConfig.ssmTokenPath, }); const environment = process.env.ENVIRONMENT; - const metadataTags = createMicrovmMetadataTags(githubRunnerConfig, environment); + const metadataTags = createMicrovmMetadataTags(normalizedGithubRunnerConfig, environment); for (let runnerIndex = 0; runnerIndex < numberOfRunners; runnerIndex++) { let microvmId: string | undefined; try { - microvmId = await runMicrovmRunner({ + const runner = await runMicrovmRunner({ config, environment, runHookPayload, - runnerOwner: githubRunnerConfig.runnerOwner, - runnerType: githubRunnerConfig.runnerType, + runnerOwner: normalizedGithubRunnerConfig.runnerOwner, + runnerType: normalizedGithubRunnerConfig.runnerType, ssmParameterStoreTags: metadataTags, source, }); + microvmId = runner.microvmId; - const failedRunnerIds = await createStartRunnerConfig(githubRunnerConfig, [microvmId], githubInstallationClient, { - getRunnerConfigMetadata: (runnerId) => [{ key: 'MicrovmId', value: runnerId }], - onJitConfigCreated: async (runnerId, metadata) => { - await setMicrovmGithubRunnerMetadata(config.metadataSsmPath, runnerId, metadata); + const failedRunnerIds = await createStartRunnerConfig( + normalizedGithubRunnerConfig, + [microvmId], + githubInstallationClient, + { + getRunnerConfigMetadata: (runnerId) => [{ key: 'MicrovmId', value: runnerId }], + onJitConfigCreated: async (runnerId, metadata) => { + await setMicrovmGithubRunnerMetadata(config, runnerId, metadata, runner.metadataTags); + }, }, - }); + ); if (failedRunnerIds.includes(microvmId)) { - await terminateMicrovm(microvmId, config.metadataSsmPath).catch((terminationError) => { + await terminateMicrovm(microvmId, config).catch((terminationError) => { logger.error(`Failed to terminate MicroVM runner '${microvmId}' after JIT configuration failed`, { error: terminationError, }); @@ -120,7 +158,7 @@ export async function createMicrovmRunners( } } catch (error) { if (microvmId) { - await terminateMicrovm(microvmId, config.metadataSsmPath).catch((terminationError) => { + await terminateMicrovm(microvmId, config).catch((terminationError) => { logger.error(`Failed to terminate MicroVM runner '${microvmId}' after setup failed`, { error: terminationError, }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts index cdae1a58a2..502fc77d05 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts @@ -1,14 +1,23 @@ -import { addParameterTags, deleteParameter, getParametersByPath, putParameter } from '@aws-github-runner/aws-ssm-util'; +import { + addParameterTags, + deleteParameter, + getParameters, + getParametersByPath, + putParameter, +} from '@aws-github-runner/aws-ssm-util'; import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + assertMatchingMicrovmRunnerTokenPath, assertSeparatedMicrovmMetadataPath, createMicrovmRunnerMetadata, - deleteMicrovmRunnerMetadata, + deleteMicrovmRunnerJitConfig, + deleteMicrovmRunnerSsmState, listMicrovmRunnerMetadata, markMicrovmCleanupPending, microvmMetadataParameterName, + microvmRunnerJitParameterName, setMicrovmGithubRunnerMetadata, setMicrovmOrphan, type MicrovmRunnerMetadata, @@ -17,11 +26,19 @@ import { vi.mock('@aws-github-runner/aws-ssm-util', () => ({ addParameterTags: vi.fn(), deleteParameter: vi.fn(), + getParameters: vi.fn(), getParametersByPath: vi.fn(), putParameter: vi.fn(), })); const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; +const runnerTokenSsmPath = '/github-action-runners/unit-test/token'; +const ssmPaths = { metadataSsmPath, runnerTokenSsmPath }; +const launchTags = [ + { Key: 'CostCenter', Value: '1234' }, + { Key: 'ghr:Application', Value: 'github-action-runner' }, + { Key: 'ghr:microvm_id', Value: 'mvm-1' }, +]; function metadata(overrides: Partial = {}): MicrovmRunnerMetadata { return { @@ -48,6 +65,7 @@ beforeEach(() => { vi.useRealTimers(); vi.mocked(deleteParameter).mockResolvedValue(); vi.mocked(addParameterTags).mockResolvedValue(); + vi.mocked(getParameters).mockImplementation(async (names) => new Map([[names[0], '{}']])); vi.mocked(getParametersByPath).mockResolvedValue(new Map()); vi.mocked(putParameter).mockResolvedValue(); }); @@ -56,6 +74,10 @@ describe('MicroVM metadata paths', () => { it('uses one base parameter per validated MicroVM ID', () => { expect(microvmMetadataParameterName(`${metadataSsmPath}/`, 'microvm-123')).toBe(`${metadataSsmPath}/microvm-123`); expect(() => microvmMetadataParameterName(metadataSsmPath, '../other')).toThrow('Invalid MicroVM identifier'); + expect(microvmRunnerJitParameterName(`${runnerTokenSsmPath}/`, 'microvm-123')).toBe( + `${runnerTokenSsmPath}/microvm-123`, + ); + expect(() => microvmRunnerJitParameterName(runnerTokenSsmPath, '../other')).toThrow('Invalid MicroVM identifier'); }); it('requires metadata to use a prefix separate from JIT configuration', () => { @@ -66,6 +88,10 @@ describe('MicroVM metadata paths', () => { 'must be separate', ); expect(() => assertSeparatedMicrovmMetadataPath('/runner', '/runner/token')).toThrow('must be separate'); + expect(() => assertMatchingMicrovmRunnerTokenPath(`${runnerTokenSsmPath}/`, runnerTokenSsmPath)).not.toThrow(); + expect(() => assertMatchingMicrovmRunnerTokenPath('/runner/other-token', runnerTokenSsmPath)).toThrow( + 'must match the runner JIT token path', + ); }); }); @@ -74,7 +100,7 @@ describe('MicroVM metadata lifecycle', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-19T10:00:00.000Z')); - await createMicrovmRunnerMetadata(metadataSsmPath, { + const createdTags = await createMicrovmRunnerMetadata(metadataSsmPath, { microvmId: 'mvm-1', environment: 'unit-test', runnerOwner: 'Codertocat', @@ -118,6 +144,7 @@ describe('MicroVM metadata lifecycle', () => { ], }, ); + expect(createdTags).toEqual(vi.mocked(putParameter).mock.calls[0][3]?.tags); }); it('rejects reserved tag keys and preserves room for late GitHub metadata', async () => { @@ -151,7 +178,26 @@ describe('MicroVM metadata lifecycle', () => { expect(putParameter).not.toHaveBeenCalled(); }); - it('loads active metadata with independent state and cleans expired inactive records', async () => { + it('rejects launch tags whose complete serialized metadata could exceed the Parameter Store value limit', async () => { + await expect( + createMicrovmRunnerMetadata(metadataSsmPath, { + microvmId: 'mvm-1', + environment: 'unit-test', + runnerOwner: 'Codertocat', + runnerType: 'Org', + source: 'scale-up-lambda', + imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', + imageVersion: '3.0', + ssmParameterStoreTags: Array.from({ length: 20 }, (_, index) => ({ + Key: `Custom${index}${'k'.repeat(100)}`, + Value: 'v'.repeat(256), + })), + }), + ).rejects.toThrow('cannot exceed 8192 bytes when serialized'); + expect(putParameter).not.toHaveBeenCalled(); + }); + + it('loads active metadata and schedules expired or invalid inactive records for two-phase cleanup', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); const active = metadata({ expiresAt: '2026-08-19T12:30:00.000Z' }); @@ -168,50 +214,66 @@ describe('MicroVM metadata lifecycle', () => { ]), ); - await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).resolves.toEqual({ - cleanupMicrovmIds: [], + await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-1', 'RUNNING']]))).resolves.toEqual({ + cleanupMicrovmIds: ['mvm-old', 'mvm-invalid'], metadataById: new Map([['mvm-1', { ...active, githubRunnerId: 'github-42', orphan: true }]]), }); expect(getParametersByPath).toHaveBeenCalledWith(metadataSsmPath); - expect(deleteParameter).toHaveBeenCalledTimes(4); - expect(deleteParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-old`); + expect(deleteParameter).not.toHaveBeenCalled(); expect(deleteParameter).not.toHaveBeenCalledWith(`${metadataSsmPath}/mvm-new`); }); - it('fails closed for invalid metadata or state belonging to an active MicroVM', async () => { + it('fails closed for invalid ownership metadata belonging to an active MicroVM', async () => { vi.mocked(getParametersByPath).mockResolvedValue(new Map([[`${metadataSsmPath}/mvm-1`, '{not-json']])); - await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( + await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( 'invalid ownership metadata', ); + }); + it('schedules provider-owned metadata with invalid orphan state for two-phase cleanup', async () => { vi.mocked(getParametersByPath).mockResolvedValue( new Map([ [`${metadataSsmPath}/mvm-1`, JSON.stringify(metadata())], [`${metadataSsmPath}/mvm-1.orphan`, 'invalid'], ]), ); - await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( - 'invalid orphan state', - ); + await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-1', 'RUNNING']]))).resolves.toEqual({ + cleanupMicrovmIds: ['mvm-1'], + metadataById: new Map(), + }); }); it('propagates metadata path lookup errors so inventory fails closed', async () => { vi.mocked(getParametersByPath).mockRejectedValue(new Error('AccessDenied')); - await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( - 'AccessDenied', - ); + await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-1', 'RUNNING']]))).rejects.toThrow('AccessDenied'); }); it('updates GitHub state and adds late GitHub metadata tags to the base parameter', async () => { const runnerLabels = ['self-hosted', 'linux', 'env:unit-test']; - await setMicrovmGithubRunnerMetadata(metadataSsmPath, 'mvm-1', { - githubRunnerId: 'github-42', - runnerLabels, + await setMicrovmGithubRunnerMetadata( + ssmPaths, + 'mvm-1', + { + githubRunnerId: 'github-42', + runnerLabels, + }, + launchTags, + ); + expect(putParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42', false, { + overwrite: true, }); - expect(putParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42', false, { + expect(putParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1.tags`, expect.any(String), false, { overwrite: true, }); + const tagsValue = vi.mocked(putParameter).mock.calls.find(([name]) => name.endsWith('.tags'))?.[1]; + expect(JSON.parse(tagsValue ?? '{}')).toEqual({ + CostCenter: '1234', + 'ghr:Application': 'github-action-runner', + 'ghr:github_runner_id': 'github-42', + 'ghr:microvm_id': 'mvm-1', + 'ghr:runner_labels': `base64url:${Buffer.from(JSON.stringify(runnerLabels), 'utf8').toString('base64url')}`, + }); expect(addParameterTags).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1`, [ { Key: 'ghr:github_runner_id', Value: 'github-42' }, { @@ -221,13 +283,53 @@ describe('MicroVM metadata lifecycle', () => { ]); }); + it('revokes JIT configuration when cleanup starts before late metadata is recorded', async () => { + vi.mocked(getParameters).mockResolvedValue( + new Map([ + [`${metadataSsmPath}/mvm-1`, '{}'], + [`${metadataSsmPath}/mvm-1.cleanup-requested-at`, '2026-08-19T12:00:00.000Z'], + ]), + ); + + await expect( + setMicrovmGithubRunnerMetadata(ssmPaths, 'mvm-1', { githubRunnerId: 'github-42', runnerLabels: [] }, launchTags), + ).rejects.toThrow('no longer accepting JIT configuration'); + expect(deleteParameter).toHaveBeenCalledWith(`${runnerTokenSsmPath}/mvm-1`); + expect(putParameter).not.toHaveBeenCalled(); + }); + + it('revokes JIT configuration when ownership metadata is already absent', async () => { + vi.mocked(getParameters).mockResolvedValue(new Map()); + + await expect( + setMicrovmGithubRunnerMetadata(ssmPaths, 'mvm-1', { githubRunnerId: 'github-42', runnerLabels: [] }, launchTags), + ).rejects.toThrow('no longer accepting JIT configuration'); + expect(deleteParameter).toHaveBeenCalledWith(`${runnerTokenSsmPath}/mvm-1`); + expect(putParameter).not.toHaveBeenCalled(); + }); + + it('revokes JIT configuration when the post-write ownership fence cannot be read', async () => { + vi.mocked(getParameters).mockRejectedValue(new Error('AccessDenied')); + + await expect( + setMicrovmGithubRunnerMetadata(ssmPaths, 'mvm-1', { githubRunnerId: 'github-42', runnerLabels: [] }, launchTags), + ).rejects.toThrow('AccessDenied'); + expect(deleteParameter).toHaveBeenCalledWith(`${runnerTokenSsmPath}/mvm-1`); + expect(putParameter).not.toHaveBeenCalled(); + }); + it('splits encoded runner labels into SSM-safe tag values', async () => { const runnerLabels = [`label-${'a'.repeat(140)}`, `label-${'b'.repeat(140)}`]; - await setMicrovmGithubRunnerMetadata(metadataSsmPath, 'mvm-1', { - githubRunnerId: 'github-42', - runnerLabels, - }); + await setMicrovmGithubRunnerMetadata( + ssmPaths, + 'mvm-1', + { + githubRunnerId: 'github-42', + runnerLabels, + }, + launchTags, + ); expect(addParameterTags).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1`, [ { Key: 'ghr:github_runner_id', Value: 'github-42' }, @@ -243,10 +345,15 @@ describe('MicroVM metadata lifecycle', () => { }); it('keeps the GitHub runner ID tag when a runner label is too large', async () => { - await setMicrovmGithubRunnerMetadata(metadataSsmPath, 'mvm-1', { - githubRunnerId: 'github-42', - runnerLabels: ['x'.repeat(300)], - }); + await setMicrovmGithubRunnerMetadata( + ssmPaths, + 'mvm-1', + { + githubRunnerId: 'github-42', + runnerLabels: ['x'.repeat(300)], + }, + launchTags, + ); expect(addParameterTags).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1`, [ { Key: 'ghr:github_runner_id', Value: 'github-42' }, @@ -257,16 +364,32 @@ describe('MicroVM metadata lifecycle', () => { vi.mocked(addParameterTags).mockRejectedValue(new Error('AccessDenied')); await expect( - setMicrovmGithubRunnerMetadata(metadataSsmPath, 'mvm-1', { - githubRunnerId: 'github-42', - runnerLabels: [], - }), + setMicrovmGithubRunnerMetadata( + ssmPaths, + 'mvm-1', + { + githubRunnerId: 'github-42', + runnerLabels: [], + }, + launchTags, + ), ).resolves.toBeUndefined(); expect(putParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42', false, { overwrite: true, }); }); + it('fails JIT setup when the canonical tag-value parameter cannot be written', async () => { + vi.mocked(putParameter).mockImplementation(async (name) => { + if (name.endsWith('.tags')) throw new Error('AccessDenied'); + }); + + await expect( + setMicrovmGithubRunnerMetadata(ssmPaths, 'mvm-1', { githubRunnerId: 'github-42', runnerLabels: [] }, launchTags), + ).rejects.toThrow('AccessDenied'); + expect(addParameterTags).not.toHaveBeenCalled(); + }); + it('updates orphan state without a shared read-modify-write record', async () => { await setMicrovmOrphan(metadataSsmPath, 'mvm-1', true); expect(putParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-1.orphan`, 'true', false, { @@ -274,7 +397,7 @@ describe('MicroVM metadata lifecycle', () => { }); }); - it('marks cleanup independently and deletes state before ownership metadata', async () => { + it('marks cleanup independently and deletes JIT plus metadata while retaining the tombstone until last', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); @@ -283,18 +406,28 @@ describe('MicroVM metadata lifecycle', () => { `${metadataSsmPath}/mvm-1.cleanup-requested-at`, '2026-08-19T12:00:00.000Z', false, - { overwrite: true }, ); - await deleteMicrovmRunnerMetadata(metadataSsmPath, 'mvm-1'); + await deleteMicrovmRunnerSsmState(ssmPaths, 'mvm-1'); expect(vi.mocked(deleteParameter).mock.calls.map(([name]) => name)).toEqual([ + `${runnerTokenSsmPath}/mvm-1`, `${metadataSsmPath}/mvm-1.github-runner-id`, `${metadataSsmPath}/mvm-1.orphan`, - `${metadataSsmPath}/mvm-1.cleanup-requested-at`, + `${metadataSsmPath}/mvm-1.tags`, `${metadataSsmPath}/mvm-1`, + `${metadataSsmPath}/mvm-1.cleanup-requested-at`, ]); }); + it('does not reset the cleanup grace window when its tombstone already exists', async () => { + vi.mocked(putParameter).mockRejectedValueOnce( + Object.assign(new Error('ParameterAlreadyExists'), { __type: 'ParameterAlreadyExists' }), + ); + + await expect(markMicrovmCleanupPending(metadataSsmPath, 'mvm-1')).resolves.toBeUndefined(); + expect(putParameter).toHaveBeenCalledOnce(); + }); + it('continues deleting metadata when optional parameters are already absent', async () => { vi.mocked(deleteParameter) .mockRejectedValueOnce( @@ -306,12 +439,14 @@ describe('MicroVM metadata lifecycle', () => { ) .mockRejectedValueOnce(Object.assign(new Error('missing parameter'), { name: 'ParameterNotFound' })); - await expect(deleteMicrovmRunnerMetadata(metadataSsmPath, 'mvm-1')).resolves.toBeUndefined(); + await expect(deleteMicrovmRunnerSsmState(ssmPaths, 'mvm-1')).resolves.toBeUndefined(); expect(vi.mocked(deleteParameter).mock.calls.map(([name]) => name)).toEqual([ + `${runnerTokenSsmPath}/mvm-1`, `${metadataSsmPath}/mvm-1.github-runner-id`, `${metadataSsmPath}/mvm-1.orphan`, - `${metadataSsmPath}/mvm-1.cleanup-requested-at`, + `${metadataSsmPath}/mvm-1.tags`, `${metadataSsmPath}/mvm-1`, + `${metadataSsmPath}/mvm-1.cleanup-requested-at`, ]); }); @@ -323,10 +458,17 @@ describe('MicroVM metadata lifecycle', () => { }); vi.mocked(deleteParameter).mockRejectedValueOnce(error); - await expect(deleteMicrovmRunnerMetadata(metadataSsmPath, 'mvm-1')).rejects.toBe(error); + await expect(deleteMicrovmRunnerSsmState(ssmPaths, 'mvm-1')).rejects.toBe(error); expect(deleteParameter).toHaveBeenCalledTimes(1); }); + it('deletes only the lane JIT parameter when revoking pending runner configuration', async () => { + await deleteMicrovmRunnerJitConfig(runnerTokenSsmPath, 'mvm-1'); + + expect(deleteParameter).toHaveBeenCalledOnce(); + expect(deleteParameter).toHaveBeenCalledWith(`${runnerTokenSsmPath}/mvm-1`); + }); + it('returns tracked and state-only active cleanup requests for termination retry', async () => { vi.mocked(getParametersByPath).mockResolvedValue( new Map([ @@ -340,7 +482,7 @@ describe('MicroVM metadata lifecycle', () => { await expect( listMicrovmRunnerMetadata( - metadataSsmPath, + ssmPaths, states([ ['mvm-1', 'RUNNING'], ['mvm-untracked', 'PENDING'], @@ -367,38 +509,122 @@ describe('MicroVM metadata lifecycle', () => { await expect( listMicrovmRunnerMetadata( - metadataSsmPath, + ssmPaths, states(cleanupIds.map((microvmId): [string, MicrovmState] => [microvmId, 'RUNNING'])), ), ).resolves.toEqual({ cleanupMicrovmIds: cleanupIds, metadataById: new Map() }); }); - it('cleans terminal state-only records and aged markers after inventory no longer sees the MicroVM', async () => { + it('keeps cleanup discoverable through the grace window before deleting JIT and every metadata record', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); vi.mocked(getParametersByPath).mockResolvedValue( new Map([ [`${metadataSsmPath}/mvm-terminal.github-runner-id`, 'github-42'], [`${metadataSsmPath}/mvm-missing.cleanup-requested-at`, '2026-08-19T11:54:59.000Z'], + [`${metadataSsmPath}/mvm-missing.tags`, '{"ghr:microvm_id":"mvm-missing"}'], [`${metadataSsmPath}/mvm-recent.cleanup-requested-at`, '2026-08-19T11:59:00.000Z'], + [`${metadataSsmPath}/mvm-recent.tags`, '{"ghr:microvm_id":"mvm-recent"}'], ]), ); - await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-terminal', 'TERMINATED']]))).resolves.toEqual( - { cleanupMicrovmIds: [], metadataById: new Map() }, - ); - expect(deleteParameter).toHaveBeenCalledTimes(8); - expect(deleteParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-terminal`); + await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-terminal', 'TERMINATED']]))).resolves.toEqual({ + cleanupMicrovmIds: ['mvm-terminal', 'mvm-recent'], + metadataById: new Map(), + }); + expect(deleteParameter).toHaveBeenCalledTimes(6); + expect(deleteParameter).toHaveBeenCalledWith(`${runnerTokenSsmPath}/mvm-missing`); expect(deleteParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-missing`); + expect(deleteParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-missing.tags`); + expect(deleteParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-missing.cleanup-requested-at`); + expect(deleteParameter).not.toHaveBeenCalledWith(`${runnerTokenSsmPath}/mvm-terminal`); + expect(deleteParameter).not.toHaveBeenCalledWith(`${runnerTokenSsmPath}/mvm-recent`); expect(deleteParameter).not.toHaveBeenCalledWith(`${metadataSsmPath}/mvm-recent`); }); + it('deletes invalid ownership metadata after its valid cleanup tombstone ages', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); + vi.mocked(getParametersByPath).mockResolvedValue( + new Map([ + [`${metadataSsmPath}/mvm-invalid`, '{not-json'], + [`${metadataSsmPath}/mvm-invalid.cleanup-requested-at`, '2026-08-19T11:54:59.000Z'], + ]), + ); + + await expect(listMicrovmRunnerMetadata(ssmPaths, new Map())).resolves.toEqual({ + cleanupMicrovmIds: [], + metadataById: new Map(), + }); + expect(vi.mocked(deleteParameter).mock.calls.map(([name]) => name)).toEqual([ + `${runnerTokenSsmPath}/mvm-invalid`, + `${metadataSsmPath}/mvm-invalid.github-runner-id`, + `${metadataSsmPath}/mvm-invalid.orphan`, + `${metadataSsmPath}/mvm-invalid.tags`, + `${metadataSsmPath}/mvm-invalid`, + `${metadataSsmPath}/mvm-invalid.cleanup-requested-at`, + ]); + }); + + it('repairs an invalid cleanup timestamp before recreating the two-phase cleanup marker', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); + vi.mocked(getParametersByPath).mockResolvedValue( + new Map([ + [`${metadataSsmPath}/mvm-1`, JSON.stringify(metadata())], + [`${metadataSsmPath}/mvm-1.cleanup-requested-at`, 'not-a-timestamp'], + ]), + ); + + await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-1', 'TERMINATED']]))).resolves.toEqual({ + cleanupMicrovmIds: ['mvm-1'], + metadataById: new Map(), + }); + expect(deleteParameter).toHaveBeenCalledOnce(); + expect(deleteParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-1.cleanup-requested-at`); + + await markMicrovmCleanupPending(metadataSsmPath, 'mvm-1'); + expect(putParameter).toHaveBeenCalledWith( + `${metadataSsmPath}/mvm-1.cleanup-requested-at`, + '2026-08-19T12:00:00.000Z', + false, + ); + + vi.clearAllMocks(); + vi.setSystemTime(new Date('2026-08-19T12:06:00.000Z')); + vi.mocked(deleteParameter).mockResolvedValue(); + vi.mocked(getParametersByPath).mockResolvedValue( + new Map([ + [`${metadataSsmPath}/mvm-1`, JSON.stringify(metadata())], + [`${metadataSsmPath}/mvm-1.cleanup-requested-at`, '2026-08-19T12:00:00.000Z'], + ]), + ); + + await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-1', 'TERMINATED']]))).resolves.toEqual({ + cleanupMicrovmIds: [], + metadataById: new Map(), + }); + expect(deleteParameter).toHaveBeenCalledTimes(6); + }); + + it('marks a terminal tags-only companion for two-phase cleanup instead of deleting it immediately', async () => { + vi.mocked(getParametersByPath).mockResolvedValue( + new Map([[`${metadataSsmPath}/mvm-tags-only.tags`, '{"ghr:microvm_id":"mvm-tags-only"}']]), + ); + + await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-tags-only', 'TERMINATED']]))).resolves.toEqual({ + cleanupMicrovmIds: ['mvm-tags-only'], + metadataById: new Map(), + }); + expect(deleteParameter).not.toHaveBeenCalled(); + }); + it('fails closed for active state metadata without ownership or a cleanup request', async () => { vi.mocked(getParametersByPath).mockResolvedValue( new Map([[`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42']]), ); - await expect(listMicrovmRunnerMetadata(metadataSsmPath, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( + await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-1', 'RUNNING']]))).rejects.toThrow( 'state metadata but no ownership metadata', ); }); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts index 4f479d04ff..f447ae135f 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -1,5 +1,11 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { addParameterTags, deleteParameter, getParametersByPath, putParameter } from '@aws-github-runner/aws-ssm-util'; +import { + addParameterTags, + deleteParameter, + getParameters, + getParametersByPath, + putParameter, +} from '@aws-github-runner/aws-ssm-util'; import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; import type { CreateGitHubRunnerConfig, GitHubRunnerMetadata, LambdaRunnerSource, RunnerType } from '../../../../core'; @@ -15,14 +21,27 @@ const MAX_RUNNER_LABEL_TAGS = 5; const MAX_BASE_PARAMETER_TAGS = MAX_PARAMETER_TAGS - MAX_RUNNER_LABEL_TAGS - 1; const MAX_TAG_KEY_LENGTH = 128; const MAX_TAG_VALUE_LENGTH = 256; +const MAX_PARAMETER_VALUE_SIZE_IN_BYTES = 8 * 1024; const SSM_TAG_VALUE_PATTERN = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u; const MICROVM_ID_PATTERN = /^[A-Za-z0-9_-]+$/; const GITHUB_RUNNER_ID_SUFFIX = '.github-runner-id'; const ORPHAN_SUFFIX = '.orphan'; const CLEANUP_REQUESTED_AT_SUFFIX = '.cleanup-requested-at'; +const TAGS_SUFFIX = '.tags'; +const METADATA_COMPANION_SUFFIXES = [ + GITHUB_RUNNER_ID_SUFFIX, + ORPHAN_SUFFIX, + CLEANUP_REQUESTED_AT_SUFFIX, + TAGS_SUFFIX, +] as const; const ACTIVE_STATES = new Set(['PENDING', 'RUNNING', 'SUSPENDING', 'SUSPENDED']); type MicrovmMetadataTag = CreateGitHubRunnerConfig['ssmParameterStoreTags'][number]; +export interface MicrovmSsmPaths { + metadataSsmPath: string; + runnerTokenSsmPath: string; +} + export interface MicrovmRunnerMetadata { bypassRemoval?: boolean; createdAt: string; @@ -89,6 +108,32 @@ function mergeParameterTags(...tagSets: MicrovmMetadataTag[][]): MicrovmMetadata return [...tagsByKey].map(([Key, Value]) => ({ Key, Value })); } +function serializeParameterTags(tags: MicrovmMetadataTag[]): string { + assertValidParameterTags(tags); + const tagValues: Record = Object.create(null) as Record; + for (const { Key, Value } of [...tags].sort((left, right) => + left.Key < right.Key ? -1 : left.Key > right.Key ? 1 : 0, + )) { + tagValues[Key] = Value; + } + + const value = JSON.stringify(tagValues); + if (Buffer.byteLength(value, 'utf8') > MAX_PARAMETER_VALUE_SIZE_IN_BYTES) { + throw new Error(`MicroVM metadata tags cannot exceed ${MAX_PARAMETER_VALUE_SIZE_IN_BYTES} bytes when serialized`); + } + return value; +} + +function maximumGitHubRunnerMetadataTags(): MicrovmMetadataTag[] { + return [ + { Key: 'ghr:github_runner_id', Value: '0'.repeat(MAX_TAG_VALUE_LENGTH) }, + ...Array.from({ length: MAX_RUNNER_LABEL_TAGS }, (_, index) => ({ + Key: index === 0 ? 'ghr:runner_labels' : `ghr:runner_labels:${index + 1}`, + Value: '0'.repeat(MAX_TAG_VALUE_LENGTH), + })), + ]; +} + function createMetadataParameterTags(input: CreateMicrovmRunnerMetadataInput): MicrovmMetadataTag[] { const configuredTags = mergeParameterTags(input.ssmParameterStoreTags).filter( (tag) => !isProviderOwnedLateTag(tag.Key) && tag.Key !== 'ghr:microvm_image_version' && tag.Key !== 'Name', @@ -113,6 +158,7 @@ function createMetadataParameterTags(input: CreateMicrovmRunnerMetadataInput): M `MicroVM metadata cannot have more than ${MAX_BASE_PARAMETER_TAGS} launch tags because ${MAX_RUNNER_LABEL_TAGS + 1} tags are reserved for GitHub runner metadata`, ); } + serializeParameterTags(mergeParameterTags(tags, maximumGitHubRunnerMetadataTags())); return tags; } @@ -168,15 +214,26 @@ function createGitHubRunnerMetadataTags(metadata: GitHubRunnerMetadata): Microvm return tags; } -function normalizedPath(path: string): string { - return path.trim().replace(/\/+$/, ''); +export function normalizeMicrovmSsmPath(path: string): string { + const normalized = path.trim().replace(/\/+$/, ''); + if (!/^\/[A-Za-z0-9_.\-/]+$/.test(normalized) || normalized.includes('//') || normalized.split('/').includes('..')) { + throw new Error(`Invalid SSM parameter path '${path}'`); + } + return normalized; } export function microvmMetadataParameterName(metadataSsmPath: string, microvmId: string): string { if (!MICROVM_ID_PATTERN.test(microvmId)) { throw new Error(`Invalid MicroVM identifier '${microvmId}'`); } - return `${normalizedPath(metadataSsmPath)}/${microvmId}`; + return `${normalizeMicrovmSsmPath(metadataSsmPath)}/${microvmId}`; +} + +export function microvmRunnerJitParameterName(runnerTokenSsmPath: string, microvmId: string): string { + if (!MICROVM_ID_PATTERN.test(microvmId)) { + throw new Error(`Invalid MicroVM identifier '${microvmId}'`); + } + return `${normalizeMicrovmSsmPath(runnerTokenSsmPath)}/${microvmId}`; } function stateParameterName(metadataSsmPath: string, microvmId: string, suffix: string): string { @@ -188,14 +245,15 @@ function metadataParameterNames(metadataSsmPath: string, microvmId: string): str return [ `${baseName}${GITHUB_RUNNER_ID_SUFFIX}`, `${baseName}${ORPHAN_SUFFIX}`, - `${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`, + `${baseName}${TAGS_SUFFIX}`, baseName, + `${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`, ]; } export function assertSeparatedMicrovmMetadataPath(metadataSsmPath: string, runnerTokenSsmPath: string): void { - const metadataPath = normalizedPath(metadataSsmPath); - const runnerTokenPath = normalizedPath(runnerTokenSsmPath); + const metadataPath = normalizeMicrovmSsmPath(metadataSsmPath); + const runnerTokenPath = normalizeMicrovmSsmPath(runnerTokenSsmPath); if ( metadataPath === runnerTokenPath || metadataPath.startsWith(`${runnerTokenPath}/`) || @@ -205,15 +263,25 @@ export function assertSeparatedMicrovmMetadataPath(metadataSsmPath: string, runn } } +export function assertMatchingMicrovmRunnerTokenPath( + configuredRunnerTokenSsmPath: string, + runnerTokenSsmPath: string, +): void { + if (normalizeMicrovmSsmPath(configuredRunnerTokenSsmPath) !== normalizeMicrovmSsmPath(runnerTokenSsmPath)) { + throw new Error('MicroVM provider SSM_TOKEN_PATH must match the runner JIT token path'); + } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isParameterError(error: unknown, type: string): boolean { + return error instanceof Error && (error.name === type || ('__type' in error && error.__type === type)); +} + function isParameterNotFound(error: unknown): boolean { - return ( - error instanceof Error && - (error.name === 'ParameterNotFound' || ('__type' in error && error.__type === 'ParameterNotFound')) - ); + return isParameterError(error, 'ParameterNotFound'); } function optionalString(value: unknown): value is string | undefined { @@ -274,7 +342,7 @@ function parseMetadata(value: string, expectedMicrovmId: string): MicrovmRunnerM export async function createMicrovmRunnerMetadata( metadataSsmPath: string, input: CreateMicrovmRunnerMetadataInput, -): Promise { +): Promise { const createdAt = new Date(); const metadata: MicrovmRunnerMetadata = { version: METADATA_VERSION, @@ -291,44 +359,37 @@ export async function createMicrovmRunnerMetadata( ).toISOString(), }; + const metadataTags = createMetadataParameterTags(input); await putParameter(microvmMetadataParameterName(metadataSsmPath, input.microvmId), JSON.stringify(metadata), false, { - tags: createMetadataParameterTags(input), + tags: metadataTags, }); + return metadataTags; } -function invalidStateReason(parameters: Map, baseName: string): string | undefined { +function invalidOrphanState(parameters: Map, baseName: string): boolean { const orphan = parameters.get(`${baseName}${ORPHAN_SUFFIX}`); - if (orphan !== undefined && orphan !== 'true' && orphan !== 'false') return 'invalid orphan state'; - - const cleanupRequestedAt = parameters.get(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); - if (cleanupRequestedAt !== undefined && !Number.isFinite(Date.parse(cleanupRequestedAt))) { - return 'invalid cleanup request timestamp'; - } - return undefined; + return orphan !== undefined && orphan !== 'true' && orphan !== 'false'; } -function shouldDeleteMetadata( - metadata: MicrovmRunnerMetadata, - state: MicrovmState | undefined, - cleanupRequestedAt: string | undefined, - now: number, -): boolean { - if (state === 'TERMINATED') return true; - if (state !== undefined) return false; +type CleanupRequestStatus = 'absent' | 'elapsed' | 'invalid' | 'pending'; - const cleanupGraceElapsed = - cleanupRequestedAt !== undefined && Date.parse(cleanupRequestedAt) + EXPIRATION_GRACE_IN_SECONDS * 1000 <= now; - return cleanupGraceElapsed || Date.parse(metadata.expiresAt) <= now; +function cleanupRequestStatus(parameters: Map, baseName: string, now: number): CleanupRequestStatus { + const cleanupRequestedAt = parameters.get(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); + if (cleanupRequestedAt === undefined) return 'absent'; + const requestedAt = Date.parse(cleanupRequestedAt); + if (!Number.isFinite(requestedAt)) return 'invalid'; + return requestedAt + EXPIRATION_GRACE_IN_SECONDS * 1000 <= now ? 'elapsed' : 'pending'; } export async function listMicrovmRunnerMetadata( - metadataSsmPath: string, + paths: MicrovmSsmPaths, microvmStates: ReadonlyMap, ): Promise { + const { metadataSsmPath } = paths; const metadataById = new Map(); const cleanupMicrovmIds = new Set(); - const parameters = await getParametersByPath(normalizedPath(metadataSsmPath)); - const parameterPrefix = `${normalizedPath(metadataSsmPath)}/`; + const parameters = await getParametersByPath(normalizeMicrovmSsmPath(metadataSsmPath)); + const parameterPrefix = `${normalizeMicrovmSsmPath(metadataSsmPath)}/`; const now = Date.now(); const metadataBaseIds = new Set(); const stateParameterIds = new Set(); @@ -336,7 +397,7 @@ export async function listMicrovmRunnerMetadata( for (const parameterName of parameters.keys()) { if (!parameterName.startsWith(parameterPrefix)) continue; - for (const suffix of [GITHUB_RUNNER_ID_SUFFIX, ORPHAN_SUFFIX, CLEANUP_REQUESTED_AT_SUFFIX]) { + for (const suffix of METADATA_COMPANION_SUFFIXES) { if (!parameterName.endsWith(suffix)) continue; const microvmId = parameterName.slice(parameterPrefix.length, -suffix.length); if (MICROVM_ID_PATTERN.test(microvmId)) stateParameterIds.add(microvmId); @@ -351,41 +412,52 @@ export async function listMicrovmRunnerMetadata( metadataBaseIds.add(microvmId); const state = microvmStates.get(microvmId); - const metadata = parseMetadata(value, microvmId); - if (!metadata) { - if (state !== undefined && ACTIVE_STATES.has(state)) { - throw new Error(`Active MicroVM runner '${microvmId}' has invalid ownership metadata`); + const baseName = microvmMetadataParameterName(metadataSsmPath, microvmId); + const cleanupStatus = cleanupRequestStatus(parameters, baseName, now); + if (cleanupStatus === 'pending' || cleanupStatus === 'elapsed') { + if (cleanupStatus === 'elapsed' && (state === undefined || state === 'TERMINATED')) { + runnersToDelete.add(microvmId); + } else { + cleanupMicrovmIds.add(microvmId); } - if (state === 'TERMINATED') runnersToDelete.add(microvmId); - else logger.warn(`Ignoring invalid MicroVM runner metadata for '${microvmId}'`); continue; } - const baseName = microvmMetadataParameterName(metadataSsmPath, microvmId); - const stateError = invalidStateReason(parameters, baseName); - if (stateError) { + const metadata = parseMetadata(value, microvmId); + if (!metadata) { if (state !== undefined && ACTIVE_STATES.has(state)) { - throw new Error(`Active MicroVM runner '${microvmId}' has ${stateError}`); + throw new Error(`Active MicroVM runner '${microvmId}' has invalid ownership metadata`); } - if (state === 'TERMINATED' || (state === undefined && Date.parse(metadata.expiresAt) <= now)) { - runnersToDelete.add(microvmId); + if (cleanupStatus === 'invalid') { + await deleteParameterIfPresent(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); } - logger.warn(`Ignoring MicroVM runner metadata for '${microvmId}' with ${stateError}`); + cleanupMicrovmIds.add(microvmId); + logger.warn(`Scheduling invalid MicroVM runner metadata for '${microvmId}' for cleanup`); continue; } - const cleanupRequestedAt = parameters.get(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); - if (shouldDeleteMetadata(metadata, state, cleanupRequestedAt, now)) { - runnersToDelete.add(microvmId); + if (cleanupStatus === 'invalid') { + await deleteParameterIfPresent(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); + cleanupMicrovmIds.add(microvmId); + logger.warn(`Repairing invalid cleanup request metadata for '${microvmId}'`); continue; } - if (state === undefined || !ACTIVE_STATES.has(state)) continue; + if (invalidOrphanState(parameters, baseName)) { + cleanupMicrovmIds.add(microvmId); + logger.warn(`Scheduling MicroVM runner metadata for '${microvmId}' with invalid orphan state for cleanup`); + continue; + } - if (cleanupRequestedAt !== undefined) { + if (state === 'TERMINATED') { cleanupMicrovmIds.add(microvmId); continue; } + if (state === undefined) { + if (Date.parse(metadata.expiresAt) <= now) cleanupMicrovmIds.add(microvmId); + continue; + } + if (!ACTIVE_STATES.has(state)) continue; metadataById.set(microvmId, { ...metadata, @@ -399,37 +471,37 @@ export async function listMicrovmRunnerMetadata( const baseName = microvmMetadataParameterName(metadataSsmPath, microvmId); const state = microvmStates.get(microvmId); - const cleanupRequestedAt = parameters.get(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); - const stateError = invalidStateReason(parameters, baseName); + const cleanupStatus = cleanupRequestStatus(parameters, baseName, now); - if (stateError && state !== undefined && ACTIVE_STATES.has(state)) { - throw new Error(`Active MicroVM runner '${microvmId}' has ${stateError}`); + if (cleanupStatus === 'pending' || cleanupStatus === 'elapsed') { + if (cleanupStatus === 'elapsed' && (state === undefined || state === 'TERMINATED')) { + runnersToDelete.add(microvmId); + } else if (state === undefined || state === 'TERMINATED' || ACTIVE_STATES.has(state)) { + cleanupMicrovmIds.add(microvmId); + } + continue; } - if (state !== undefined && ACTIVE_STATES.has(state)) { - if (cleanupRequestedAt === undefined) { - throw new Error(`Active MicroVM runner '${microvmId}' has state metadata but no ownership metadata`); + + if (cleanupStatus === 'invalid') { + if (state !== undefined && ACTIVE_STATES.has(state)) { + throw new Error(`Active MicroVM runner '${microvmId}' has an invalid cleanup request timestamp`); } + await deleteParameterIfPresent(`${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`); cleanupMicrovmIds.add(microvmId); continue; } - if (state === 'TERMINATED') { - runnersToDelete.add(microvmId); - continue; + + if (state !== undefined && ACTIVE_STATES.has(state)) { + throw new Error(`Active MicroVM runner '${microvmId}' has state metadata but no ownership metadata`); } - if (state === undefined) { - const cleanupGraceElapsed = - cleanupRequestedAt !== undefined && - Number.isFinite(Date.parse(cleanupRequestedAt)) && - Date.parse(cleanupRequestedAt) + EXPIRATION_GRACE_IN_SECONDS * 1000 <= now; - if (cleanupRequestedAt === undefined || stateError !== undefined || cleanupGraceElapsed) { - runnersToDelete.add(microvmId); - } + if (state === 'TERMINATED' || state === undefined) { + cleanupMicrovmIds.add(microvmId); } } for (const microvmId of [...runnersToDelete].slice(0, MAX_RECONCILED_RUNNERS)) { try { - await deleteMicrovmRunnerMetadata(metadataSsmPath, microvmId); + await deleteMicrovmRunnerSsmState(paths, microvmId); } catch (error) { logger.warn(`Failed to delete reconciled MicroVM runner metadata '${microvmId}'`, { error }); } @@ -442,24 +514,40 @@ export async function listMicrovmRunnerMetadata( } export async function setMicrovmGithubRunnerMetadata( - metadataSsmPath: string, + paths: MicrovmSsmPaths, microvmId: string, metadata: GitHubRunnerMetadata, + launchTags: MicrovmMetadataTag[], ): Promise { if (!metadata.githubRunnerId) throw new Error('GitHub runner ID must not be empty'); + const baseName = microvmMetadataParameterName(paths.metadataSsmPath, microvmId); + const cleanupMarkerName = `${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`; + try { + const parameters = await getParameters([baseName, cleanupMarkerName]); + if (!parameters.has(baseName) || parameters.has(cleanupMarkerName)) { + throw new Error(`MicroVM runner '${microvmId}' is no longer accepting JIT configuration`); + } + } catch (error) { + await deleteMicrovmRunnerJitConfig(paths.runnerTokenSsmPath, microvmId); + throw error; + } + + const githubRunnerTags = createGitHubRunnerMetadataTags(metadata); + const tags = mergeParameterTags(launchTags, githubRunnerTags); + const serializedTags = serializeParameterTags(tags); await putParameter( - stateParameterName(metadataSsmPath, microvmId, GITHUB_RUNNER_ID_SUFFIX), + stateParameterName(paths.metadataSsmPath, microvmId, GITHUB_RUNNER_ID_SUFFIX), metadata.githubRunnerId, false, { overwrite: true, }, ); + await putParameter(stateParameterName(paths.metadataSsmPath, microvmId, TAGS_SUFFIX), serializedTags, false, { + overwrite: true, + }); try { - await addParameterTags( - microvmMetadataParameterName(metadataSsmPath, microvmId), - createGitHubRunnerMetadataTags(metadata), - ); + await addParameterTags(baseName, githubRunnerTags); } catch (error) { logger.error(`Failed to tag MicroVM runner '${microvmId}' with GitHub runner metadata`, { error }); } @@ -472,20 +560,32 @@ export async function setMicrovmOrphan(metadataSsmPath: string, microvmId: strin } export async function markMicrovmCleanupPending(metadataSsmPath: string, microvmId: string): Promise { - await putParameter( - stateParameterName(metadataSsmPath, microvmId, CLEANUP_REQUESTED_AT_SUFFIX), - new Date().toISOString(), - false, - { overwrite: true }, - ); + try { + await putParameter( + stateParameterName(metadataSsmPath, microvmId, CLEANUP_REQUESTED_AT_SUFFIX), + new Date().toISOString(), + false, + ); + } catch (error) { + if (!isParameterError(error, 'ParameterAlreadyExists')) throw error; + } } -export async function deleteMicrovmRunnerMetadata(metadataSsmPath: string, microvmId: string): Promise { - for (const parameterName of metadataParameterNames(metadataSsmPath, microvmId)) { - try { - await deleteParameter(parameterName); - } catch (error) { - if (!isParameterNotFound(error)) throw error; - } +async function deleteParameterIfPresent(parameterName: string): Promise { + try { + await deleteParameter(parameterName); + } catch (error) { + if (!isParameterNotFound(error)) throw error; + } +} + +export async function deleteMicrovmRunnerJitConfig(runnerTokenSsmPath: string, microvmId: string): Promise { + await deleteParameterIfPresent(microvmRunnerJitParameterName(runnerTokenSsmPath, microvmId)); +} + +export async function deleteMicrovmRunnerSsmState(paths: MicrovmSsmPaths, microvmId: string): Promise { + await deleteMicrovmRunnerJitConfig(paths.runnerTokenSsmPath, microvmId); + for (const parameterName of metadataParameterNames(paths.metadataSsmPath, microvmId)) { + await deleteParameterIfPresent(parameterName); } } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts index 02818fb939..f98eb88628 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts @@ -15,10 +15,12 @@ vi.mock('./runner-metadata', () => ({ setMicrovmOrphan: vi.fn() })); const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; +const runnerTokenSsmPath = '/github-action-runners/unit-test/token'; const providerConfig = { imageIdentifier: imageArn, executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath, + runnerTokenSsmPath, }; beforeEach(() => { @@ -43,7 +45,7 @@ describe('createMicrovmScaleDownProvider', () => { environment: 'unit-test', orphan: undefined, }, - metadataSsmPath, + providerConfig, ); expect(listMicrovmRunners).toHaveBeenNthCalledWith( 2, @@ -51,7 +53,7 @@ describe('createMicrovmScaleDownProvider', () => { environment: 'unit-test', orphan: true, }, - metadataSsmPath, + providerConfig, ); }); @@ -64,7 +66,7 @@ describe('createMicrovmScaleDownProvider', () => { expect(setMicrovmOrphan).toHaveBeenNthCalledWith(1, metadataSsmPath, 'mvm-1', true); expect(setMicrovmOrphan).toHaveBeenNthCalledWith(2, metadataSsmPath, 'mvm-1', false); - expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', metadataSsmPath); + expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', providerConfig); }); it('uses the MicroVM boot-time policy', () => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts index 9cda68cf53..82038711df 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts @@ -5,17 +5,17 @@ import { listMicrovmRunners, microvmBootTimeExceeded, terminateMicrovm } from '. import { setMicrovmOrphan } from './runner-metadata'; export function createMicrovmScaleDownProvider(): Omit { - const metadataSsmPath = () => loadMicrovmProviderConfig().metadataSsmPath; + const ssmPaths = () => loadMicrovmProviderConfig(); async function list(environment: string, orphan?: boolean): Promise { - return await listMicrovmRunners({ environment, orphan }, metadataSsmPath()); + return await listMicrovmRunners({ environment, orphan }, ssmPaths()); } return { list, bootTimeExceeded: microvmBootTimeExceeded, - markOrphan: async (id) => await setMicrovmOrphan(metadataSsmPath(), id, true), - unmarkOrphan: async (id) => await setMicrovmOrphan(metadataSsmPath(), id, false), - terminate: async (id) => await terminateMicrovm(id, metadataSsmPath()), + markOrphan: async (id) => await setMicrovmOrphan(ssmPaths().metadataSsmPath, id, true), + unmarkOrphan: async (id) => await setMicrovmOrphan(ssmPaths().metadataSsmPath, id, false), + terminate: async (id) => await terminateMicrovm(id, ssmPaths()), }; } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts index 58cf080e5e..0111373247 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts @@ -10,6 +10,7 @@ declare global { MICROVM_INGRESS_NETWORK_CONNECTORS: string | undefined; MICROVM_LOG_GROUP: string | undefined; MICROVM_METADATA_SSM_PATH: string; + SSM_TOKEN_PATH: string; } } } From dd27237950b21a518d11a7009991e47e54eb9f84 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 2 Sep 2026 19:20:49 +0200 Subject: [PATCH 36/54] fix(deps): align Lambda lockfile after rebase --- lambdas/yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 0d17470f77..4eb03fcab0 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -1376,7 +1376,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" dependencies: From 72a771f93e2ca2203418d9d64df89094929efacc Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 3 Sep 2026 22:18:08 +0200 Subject: [PATCH 37/54] fix(scale-runners): log JIT setup after provider callback --- .../control-plane/src/scale-runners/github-runner.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index 47da7cae9a..25974007d8 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -304,9 +304,6 @@ async function createJitConfig( metricGitHubAppRateLimit(runnerConfig.headers, githubRunnerConfig.appIndex); - logger.debug('Runner JIT config for ephemeral runner generated.', { - instance: runnerId, - }); await runnerConfigStore.create( { runnerId, value: runnerConfig.data.encoded_jit_config }, { metadata: options.getRunnerConfigMetadata?.(runnerId) }, @@ -315,6 +312,9 @@ async function createJitConfig( githubRunnerId: runnerConfig.data.runner.id.toString(), runnerLabels, }); + logger.debug('Runner JIT config for ephemeral runner generated.', { + instance: runnerId, + }); if (isDelay) { // Delay to stay within the selected store's maximum write throughput. await delay(delayMilliseconds); From 7feb2d284627af83093fd9da2cd6eb1082fbb30d Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 3 Sep 2026 22:25:06 +0200 Subject: [PATCH 38/54] fix(scale-runners): restore provider callback ordering --- .../src/scale-runners/github-runner.test.ts | 19 ++++++++++--------- .../src/scale-runners/github-runner.ts | 8 ++++---- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts index 4f687b1644..65e479f191 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts @@ -42,14 +42,9 @@ beforeEach(() => { }); describe('createStartRunnerConfig', () => { - it('persists JIT configuration before notifying the provider', async () => { + it('notifies the provider before persisting JIT configuration', async () => { const onJitConfigCreated = vi.fn(async () => { - expect(putParameter).toHaveBeenCalledWith( - '/github-action-runners/test/tokens/microvm-1', - 'encoded-jit-config', - true, - { tags: [] }, - ); + expect(putParameter).not.toHaveBeenCalled(); }); await expect( @@ -59,14 +54,20 @@ describe('createStartRunnerConfig', () => { githubRunnerId: '42', runnerLabels: ['self-hosted', 'linux'], }); + expect(putParameter).toHaveBeenCalledWith( + '/github-action-runners/test/tokens/microvm-1', + 'encoded-jit-config', + true, + { tags: [] }, + ); }); - it('reports provider post-write fencing failures while leaving cleanup to the provider', async () => { + it('reports provider failures without persisting JIT configuration', async () => { const onJitConfigCreated = vi.fn().mockRejectedValue(new Error('cleanup already requested')); await expect( createStartRunnerConfig(githubRunnerConfig, ['microvm-1'], githubClient, { onJitConfigCreated }), ).resolves.toEqual(['microvm-1']); - expect(putParameter).toHaveBeenCalledOnce(); + expect(putParameter).not.toHaveBeenCalled(); }); }); diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index 25974007d8..28395d9d4f 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -304,10 +304,6 @@ async function createJitConfig( metricGitHubAppRateLimit(runnerConfig.headers, githubRunnerConfig.appIndex); - await runnerConfigStore.create( - { runnerId, value: runnerConfig.data.encoded_jit_config }, - { metadata: options.getRunnerConfigMetadata?.(runnerId) }, - ); await options.onJitConfigCreated?.(runnerId, { githubRunnerId: runnerConfig.data.runner.id.toString(), runnerLabels, @@ -315,6 +311,10 @@ async function createJitConfig( logger.debug('Runner JIT config for ephemeral runner generated.', { instance: runnerId, }); + await runnerConfigStore.create( + { runnerId, value: runnerConfig.data.encoded_jit_config }, + { metadata: options.getRunnerConfigMetadata?.(runnerId) }, + ); if (isDelay) { // Delay to stay within the selected store's maximum write throughput. await delay(delayMilliseconds); From 67a37177a7e16261bad9e7fb18f494b78ed186d6 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 3 Sep 2026 22:26:47 +0200 Subject: [PATCH 39/54] revert(scale-runners): restore JIT callback ordering --- .../src/scale-runners/github-runner.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts index 65e479f191..4f687b1644 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts @@ -42,9 +42,14 @@ beforeEach(() => { }); describe('createStartRunnerConfig', () => { - it('notifies the provider before persisting JIT configuration', async () => { + it('persists JIT configuration before notifying the provider', async () => { const onJitConfigCreated = vi.fn(async () => { - expect(putParameter).not.toHaveBeenCalled(); + expect(putParameter).toHaveBeenCalledWith( + '/github-action-runners/test/tokens/microvm-1', + 'encoded-jit-config', + true, + { tags: [] }, + ); }); await expect( @@ -54,20 +59,14 @@ describe('createStartRunnerConfig', () => { githubRunnerId: '42', runnerLabels: ['self-hosted', 'linux'], }); - expect(putParameter).toHaveBeenCalledWith( - '/github-action-runners/test/tokens/microvm-1', - 'encoded-jit-config', - true, - { tags: [] }, - ); }); - it('reports provider failures without persisting JIT configuration', async () => { + it('reports provider post-write fencing failures while leaving cleanup to the provider', async () => { const onJitConfigCreated = vi.fn().mockRejectedValue(new Error('cleanup already requested')); await expect( createStartRunnerConfig(githubRunnerConfig, ['microvm-1'], githubClient, { onJitConfigCreated }), ).resolves.toEqual(['microvm-1']); - expect(putParameter).not.toHaveBeenCalled(); + expect(putParameter).toHaveBeenCalledOnce(); }); }); From 81c5340fa19707bab063e3776106bf1604d9ad3d Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 3 Sep 2026 22:30:10 +0200 Subject: [PATCH 40/54] refactor(tests): keep MicroVM coverage in provider layer --- .../src/scale-runners/github-runner.test.ts | 72 ------------------- .../src/scale-runners/scale-up.test.ts | 19 ----- 2 files changed, 91 deletions(-) delete mode 100644 lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts deleted file mode 100644 index 4f687b1644..0000000000 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { putParameter } from '@aws-github-runner/aws-ssm-util'; -import type { Octokit } from '@octokit/rest'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { createStartRunnerConfig } from './github-runner'; -import type { CreateGitHubRunnerConfig } from './types'; - -vi.mock('@aws-github-runner/aws-ssm-util', () => ({ - getParameter: vi.fn(), - putParameter: vi.fn(), -})); - -const githubRunnerConfig: CreateGitHubRunnerConfig = { - disableAutoUpdate: true, - enableJitConfig: true, - ephemeral: true, - runnerGroup: 'Default', - runnerLabels: 'self-hosted,linux', - runnerNamePrefix: 'runner-', - runnerOwner: 'octocat/runner', - runnerType: 'Repo', - ssmConfigPath: '/github-action-runners/test/config', - ssmParameterStoreTags: [], - ssmTokenPath: '/github-action-runners/test/tokens', -}; - -const generateRunnerJitconfigForRepo = vi.fn(); -const githubClient = { - actions: { generateRunnerJitconfigForRepo }, -} as unknown as Octokit; - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(putParameter).mockResolvedValue(); - generateRunnerJitconfigForRepo.mockResolvedValue({ - data: { - encoded_jit_config: 'encoded-jit-config', - runner: { id: 42 }, - }, - headers: {}, - }); -}); - -describe('createStartRunnerConfig', () => { - it('persists JIT configuration before notifying the provider', async () => { - const onJitConfigCreated = vi.fn(async () => { - expect(putParameter).toHaveBeenCalledWith( - '/github-action-runners/test/tokens/microvm-1', - 'encoded-jit-config', - true, - { tags: [] }, - ); - }); - - await expect( - createStartRunnerConfig(githubRunnerConfig, ['microvm-1'], githubClient, { onJitConfigCreated }), - ).resolves.toEqual([]); - expect(onJitConfigCreated).toHaveBeenCalledWith('microvm-1', { - githubRunnerId: '42', - runnerLabels: ['self-hosted', 'linux'], - }); - }); - - it('reports provider post-write fencing failures while leaving cleanup to the provider', async () => { - const onJitConfigCreated = vi.fn().mockRejectedValue(new Error('cleanup already requested')); - - await expect( - createStartRunnerConfig(githubRunnerConfig, ['microvm-1'], githubClient, { onJitConfigCreated }), - ).resolves.toEqual(['microvm-1']); - expect(putParameter).toHaveBeenCalledOnce(); - }); -}); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index d6b2d6a096..664bac60fb 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -417,25 +417,6 @@ describe('scaleUp with GHES', () => { }); }); - it.each([true, false])( - 'keeps the provider runner identity tag authoritative for ephemeral=%s', - async (ephemeral) => { - process.env.ENABLE_EPHEMERAL_RUNNERS = String(ephemeral); - process.env.RUNNERS_MAXIMUM_COUNT = '2'; - process.env.SSM_PARAMETER_STORE_TAGS = JSON.stringify([ - { Key: 'RunnerId', Value: 'configured-value-cannot-win' }, - { Key: 'CostCenter', Value: '1234' }, - ]); - - await scaleUpModule.scaleUp(TEST_DATA); - - expect(mockSSMClient.commandCalls(PutParameterCommand)[0].args[0].input.Tags).toEqual([ - { Key: 'RunnerId', Value: 'i-12345' }, - { Key: 'CostCenter', Value: '1234' }, - ]); - }, - ); - it('quotes runner labels with semicolon separators in non-ephemeral runner config', async () => { process.env.ENABLE_EPHEMERAL_RUNNERS = 'false'; process.env.RUNNERS_MAXIMUM_COUNT = '2'; From 926634483b59050e37d908cc8406d3a242a14387 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 3 Sep 2026 22:37:22 +0200 Subject: [PATCH 41/54] fix(microvm): use shared runner source type --- .../aws/microvm/src/control-plane/microvms.ts | 4 ++-- .../aws/microvm/src/control-plane/runner-config.ts | 4 ++-- .../aws/microvm/src/control-plane/runner-metadata.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index 5ffd6d74b5..3d9f3431e4 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -11,7 +11,7 @@ import type { MicrovmItem, MicrovmState, RunMicrovmCommandInput } from '@aws-sdk import type { CreateGitHubRunnerConfig, - LambdaRunnerSource, + RunnerSource, ListRunnerFilters, RunnerInfo, RunnerType, @@ -43,7 +43,7 @@ export interface RunMicrovmRunnerInput { runnerOwner: string; runnerType: RunnerType; ssmParameterStoreTags: CreateGitHubRunnerConfig['ssmParameterStoreTags']; - source: LambdaRunnerSource; + source: RunnerSource; } export interface RunMicrovmRunnerResult { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts index 251c15dfe7..28f17d5308 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -5,7 +5,7 @@ import type { CreateGitHubRunnerConfig, CreateRunnerResult, CreateStartRunnerConfig, - LambdaRunnerSource, + RunnerSource, } from '../../../../core'; import type { MicrovmDynamicLabelOverrides } from '../dynamic-labels'; import { loadMicrovmProviderConfig } from './config'; @@ -70,7 +70,7 @@ export async function createMicrovmRunners( numberOfRunners: number, githubInstallationClient: Octokit, createStartRunnerConfig: CreateStartRunnerConfig, - source: LambdaRunnerSource, + source: RunnerSource, overrides: MicrovmDynamicLabelOverrides = {}, ): Promise { if (!githubRunnerConfig.ephemeral || !githubRunnerConfig.enableJitConfig) { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts index f447ae135f..883a144f15 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -8,7 +8,7 @@ import { } from '@aws-github-runner/aws-ssm-util'; import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; -import type { CreateGitHubRunnerConfig, GitHubRunnerMetadata, LambdaRunnerSource, RunnerType } from '../../../../core'; +import type { CreateGitHubRunnerConfig, GitHubRunnerMetadata, RunnerSource, RunnerType } from '../../../../core'; import { MICROVM_LIFETIME_IN_SECONDS } from './lifetime'; const logger = createChildLogger('microvm-runner-metadata'); @@ -54,7 +54,7 @@ export interface MicrovmRunnerMetadata { orphan?: boolean; runnerOwner: string; runnerType: RunnerType; - source: LambdaRunnerSource; + source: RunnerSource; version: 1; } @@ -71,7 +71,7 @@ export interface CreateMicrovmRunnerMetadataInput { runnerOwner: string; runnerType: RunnerType; ssmParameterStoreTags: MicrovmMetadataTag[]; - source: LambdaRunnerSource; + source: RunnerSource; } function isProviderOwnedLateTag(key: string): boolean { From a81e744b61ee5fd1de693b99ed9e4eba69bff205 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 3 Sep 2026 22:45:23 +0200 Subject: [PATCH 42/54] fix(scale-runners): preserve existing JIT config ordering --- .../functions/control-plane/src/scale-runners/github-runner.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index 28395d9d4f..2a87739e83 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -308,6 +308,8 @@ async function createJitConfig( githubRunnerId: runnerConfig.data.runner.id.toString(), runnerLabels, }); + + // Store the JIT config through the selected storage provider. logger.debug('Runner JIT config for ephemeral runner generated.', { instance: runnerId, }); From c807a4ae8a03676f254d670afb7a5ace284883e6 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 00:57:18 +0200 Subject: [PATCH 43/54] fix(microvm): read SSM settings from environment --- .../aws/microvm/src/control-plane/microvms.ts | 13 +-- .../microvm/src/control-plane/pool.test.ts | 3 - .../src/control-plane/runner-config.test.ts | 41 +++------ .../src/control-plane/runner-config.ts | 86 +++++++++++++------ .../src/control-plane/runner-metadata.ts | 7 +- .../src/control-plane/scale-up.test.ts | 3 - .../aws/microvm/src/environment.d.ts | 2 + 7 files changed, 81 insertions(+), 74 deletions(-) diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index 3d9f3431e4..3293db55ba 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -9,13 +9,7 @@ import { } from '@aws-sdk/client-lambda-microvms'; import type { MicrovmItem, MicrovmState, RunMicrovmCommandInput } from '@aws-sdk/client-lambda-microvms'; -import type { - CreateGitHubRunnerConfig, - RunnerSource, - ListRunnerFilters, - RunnerInfo, - RunnerType, -} from '../../../../core'; +import type { RunnerSource, ListRunnerFilters, RunnerInfo, RunnerType } from '../../../../core'; import { loadMicrovmProviderConfig, type MicrovmProviderConfig } from './config'; import { MICROVM_LIFETIME_IN_SECONDS } from './lifetime'; import { @@ -24,6 +18,7 @@ import { deleteMicrovmRunnerJitConfig, listMicrovmRunnerMetadata, markMicrovmCleanupPending, + type MicrovmMetadataTag, type MicrovmSsmPaths, } from './runner-metadata'; @@ -42,12 +37,12 @@ export interface RunMicrovmRunnerInput { runHookPayload: string; runnerOwner: string; runnerType: RunnerType; - ssmParameterStoreTags: CreateGitHubRunnerConfig['ssmParameterStoreTags']; + ssmParameterStoreTags: MicrovmMetadataTag[]; source: RunnerSource; } export interface RunMicrovmRunnerResult { - metadataTags: CreateGitHubRunnerConfig['ssmParameterStoreTags']; + metadataTags: MicrovmMetadataTag[]; microvmId: string; } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.test.ts index 8f46818b50..719a0058c1 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/pool.test.ts @@ -29,9 +29,6 @@ function githubRunnerConfig(): CreateGitHubRunnerConfig { runnerOwner: 'Codertocat', runnerType: 'Org', disableAutoUpdate: true, - ssmTokenPath: '/runner/token', - ssmConfigPath: '/runner/config', - ssmParameterStoreTags: [], }; } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts index ce7262049e..6d3f92fc76 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts @@ -60,9 +60,6 @@ function runnerConfig(overrides: Partial = {}): Create runnerOwner: 'Codertocat', runnerType: 'Org', disableAutoUpdate: true, - ssmTokenPath: runnerTokenSsmPath, - ssmConfigPath: '/github-action-runners/unit-test/config', - ssmParameterStoreTags, ...overrides, }; } @@ -70,6 +67,9 @@ function runnerConfig(overrides: Partial = {}): Create beforeEach(() => { vi.clearAllMocks(); process.env.ENVIRONMENT = 'unit-test'; + process.env.SSM_CONFIG_PATH = runnerConfigSsmPath; + process.env.SSM_PARAMETER_STORE_TAGS = JSON.stringify(ssmParameterStoreTags); + process.env.SSM_TOKEN_PATH = runnerTokenSsmPath; vi.mocked(loadMicrovmProviderConfig).mockReturnValue(providerConfig); vi.mocked(runMicrovmRunner).mockResolvedValue({ microvmId: 'mvm-1', metadataTags: canonicalMetadataTags }); vi.mocked(setMicrovmGithubRunnerMetadata).mockResolvedValue(); @@ -130,28 +130,18 @@ describe('createMicrovmRunners', () => { ); it('requires an SSM token path', async () => { + process.env.SSM_TOKEN_PATH = ''; await expect( - createMicrovmRunners( - runnerConfig({ ssmTokenPath: '' }), - 1, - githubClient, - createStartRunnerConfig, - 'scale-up-lambda', - ), + createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); expect(runMicrovmRunner).not.toHaveBeenCalled(); }); it('requires an SSM config path', async () => { + process.env.SSM_CONFIG_PATH = ''; await expect( - createMicrovmRunners( - runnerConfig({ ssmConfigPath: '' }), - 1, - githubClient, - createStartRunnerConfig, - 'scale-up-lambda', - ), + createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), ).resolves.toEqual({ instances: [], retryableErrorCount: 0, nonRetryableErrorCount: 1 }); expect(runMicrovmRunner).not.toHaveBeenCalled(); @@ -172,14 +162,10 @@ describe('createMicrovmRunners', () => { }); it('canonicalizes the configuration and token paths before launching or writing JIT configuration', async () => { + process.env.SSM_CONFIG_PATH = `${runnerConfigSsmPath}/`; + process.env.SSM_TOKEN_PATH = `${runnerTokenSsmPath}/`; await expect( - createMicrovmRunners( - runnerConfig({ ssmConfigPath: `${runnerConfigSsmPath}/`, ssmTokenPath: `${runnerTokenSsmPath}/` }), - 1, - githubClient, - createStartRunnerConfig, - 'scale-up-lambda', - ), + createMicrovmRunners(runnerConfig(), 1, githubClient, createStartRunnerConfig, 'scale-up-lambda'), ).resolves.toEqual({ instances: ['mvm-1'], retryableErrorCount: 0, nonRetryableErrorCount: 0 }); expect(runMicrovmRunner).toHaveBeenCalledWith( @@ -193,12 +179,7 @@ describe('createMicrovmRunners', () => { ssmParameterStoreTags: microvmMetadataTags, }), ); - expect(createStartRunnerConfig).toHaveBeenCalledWith( - expect.objectContaining({ ssmConfigPath: runnerConfigSsmPath, ssmTokenPath: runnerTokenSsmPath }), - ['mvm-1'], - githubClient, - expect.any(Object), - ); + expect(createStartRunnerConfig).toHaveBeenCalledWith(runnerConfig(), ['mvm-1'], githubClient, expect.any(Object)); }); it('classifies invalid provider configuration as non-retryable', async () => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts index 28f17d5308..9d7154707b 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -14,6 +14,7 @@ import { assertMatchingMicrovmRunnerTokenPath, assertSeparatedMicrovmMetadataPath, normalizeMicrovmSsmPath, + type MicrovmMetadataTag, setMicrovmGithubRunnerMetadata, } from './runner-metadata'; @@ -56,15 +57,46 @@ export function createMicrovmRunHookPayload(payload: Omit !MICROVM_METADATA_CONTEXT_TAG_KEYS.has(tag.Key)), + ...ssmParameterStoreTags.filter((tag) => !MICROVM_METADATA_CONTEXT_TAG_KEYS.has(tag.Key)), { Key: 'ghr:environment', Value: environment }, { Key: 'ghr:runner_name_prefix', Value: config.runnerNamePrefix }, - { Key: 'ghr:ssm_config_path', Value: config.ssmConfigPath }, + { Key: 'ghr:ssm_config_path', Value: ssmConfigPath }, ]; } +function loadSsmParameterStoreTags(): MicrovmMetadataTag[] { + const encodedTags = process.env.SSM_PARAMETER_STORE_TAGS; + if (encodedTags === undefined || encodedTags.trim() === '') { + return []; + } + + try { + const parsed: unknown = JSON.parse(encodedTags); + if (!Array.isArray(parsed)) { + throw new Error('tags must be an array'); + } + + return parsed.map((tag, index) => { + if ( + tag === null || + typeof tag !== 'object' || + typeof (tag as Record).Key !== 'string' || + typeof (tag as Record).Value !== 'string' + ) { + throw new Error(`tag at index ${index} is invalid`); + } + const candidate = tag as Record; + return { Key: candidate.Key as string, Value: candidate.Value as string }; + }); + } catch (error) { + throw new Error(`Failed to parse SSM_PARAMETER_STORE_TAGS: ${(error as Error).message}`); + } +} + export async function createMicrovmRunners( githubRunnerConfig: CreateGitHubRunnerConfig, numberOfRunners: number, @@ -78,25 +110,25 @@ export async function createMicrovmRunners( return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; } - if (!githubRunnerConfig.ssmTokenPath?.trim()) { + if (!process.env.SSM_TOKEN_PATH?.trim()) { logger.error('Lambda MicroVM runners require SSM_TOKEN_PATH to deliver JIT configuration'); return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; } - if (!githubRunnerConfig.ssmConfigPath?.trim()) { + if (!process.env.SSM_CONFIG_PATH?.trim()) { logger.error('Lambda MicroVM runners require SSM_CONFIG_PATH to locate runner metadata'); return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; } let config; - let normalizedGithubRunnerConfig: CreateGitHubRunnerConfig; + let normalizedRunnerConfigPath: string; + let normalizedRunnerTokenPath: string; + let ssmParameterStoreTags: MicrovmMetadataTag[]; try { config = { ...loadMicrovmProviderConfig(), ...overrides }; - assertMatchingMicrovmRunnerTokenPath(config.runnerTokenSsmPath, githubRunnerConfig.ssmTokenPath); + normalizedRunnerConfigPath = normalizeMicrovmSsmPath(process.env.SSM_CONFIG_PATH); + normalizedRunnerTokenPath = normalizeMicrovmSsmPath(process.env.SSM_TOKEN_PATH); + assertMatchingMicrovmRunnerTokenPath(config.runnerTokenSsmPath, normalizedRunnerTokenPath); assertSeparatedMicrovmMetadataPath(config.metadataSsmPath, config.runnerTokenSsmPath); - normalizedGithubRunnerConfig = { - ...githubRunnerConfig, - ssmConfigPath: normalizeMicrovmSsmPath(githubRunnerConfig.ssmConfigPath), - ssmTokenPath: config.runnerTokenSsmPath, - }; + ssmParameterStoreTags = loadSsmParameterStoreTags(); } catch (error) { logger.error('Invalid Lambda MicroVM provider configuration', { error }); return { instances: [], retryableErrorCount: 0, nonRetryableErrorCount: numberOfRunners }; @@ -114,11 +146,16 @@ export async function createMicrovmRunners( imageVersion: config.imageVersion, } : {}), - runnerConfigSsmPath: normalizedGithubRunnerConfig.ssmConfigPath, - runnerTokenSsmPath: normalizedGithubRunnerConfig.ssmTokenPath, + runnerConfigSsmPath: normalizedRunnerConfigPath, + runnerTokenSsmPath: normalizedRunnerTokenPath, }); const environment = process.env.ENVIRONMENT; - const metadataTags = createMicrovmMetadataTags(normalizedGithubRunnerConfig, environment); + const metadataTags = createMicrovmMetadataTags( + githubRunnerConfig, + environment, + normalizedRunnerConfigPath, + ssmParameterStoreTags, + ); for (let runnerIndex = 0; runnerIndex < numberOfRunners; runnerIndex++) { let microvmId: string | undefined; @@ -127,24 +164,19 @@ export async function createMicrovmRunners( config, environment, runHookPayload, - runnerOwner: normalizedGithubRunnerConfig.runnerOwner, - runnerType: normalizedGithubRunnerConfig.runnerType, + runnerOwner: githubRunnerConfig.runnerOwner, + runnerType: githubRunnerConfig.runnerType, ssmParameterStoreTags: metadataTags, source, }); microvmId = runner.microvmId; - const failedRunnerIds = await createStartRunnerConfig( - normalizedGithubRunnerConfig, - [microvmId], - githubInstallationClient, - { - getRunnerConfigMetadata: (runnerId) => [{ key: 'MicrovmId', value: runnerId }], - onJitConfigCreated: async (runnerId, metadata) => { - await setMicrovmGithubRunnerMetadata(config, runnerId, metadata, runner.metadataTags); - }, + const failedRunnerIds = await createStartRunnerConfig(githubRunnerConfig, [microvmId], githubInstallationClient, { + getRunnerConfigMetadata: (runnerId) => [{ key: 'MicrovmId', value: runnerId }], + onJitConfigCreated: async (runnerId, metadata) => { + await setMicrovmGithubRunnerMetadata(config, runnerId, metadata, runner.metadataTags); }, - ); + }); if (failedRunnerIds.includes(microvmId)) { await terminateMicrovm(microvmId, config).catch((terminationError) => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts index 883a144f15..0bae7a8d80 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -8,7 +8,7 @@ import { } from '@aws-github-runner/aws-ssm-util'; import type { MicrovmState } from '@aws-sdk/client-lambda-microvms'; -import type { CreateGitHubRunnerConfig, GitHubRunnerMetadata, RunnerSource, RunnerType } from '../../../../core'; +import type { GitHubRunnerMetadata, RunnerSource, RunnerType } from '../../../../core'; import { MICROVM_LIFETIME_IN_SECONDS } from './lifetime'; const logger = createChildLogger('microvm-runner-metadata'); @@ -35,7 +35,10 @@ const METADATA_COMPANION_SUFFIXES = [ TAGS_SUFFIX, ] as const; const ACTIVE_STATES = new Set(['PENDING', 'RUNNING', 'SUSPENDING', 'SUSPENDED']); -type MicrovmMetadataTag = CreateGitHubRunnerConfig['ssmParameterStoreTags'][number]; +export interface MicrovmMetadataTag { + Key: string; + Value: string; +} export interface MicrovmSsmPaths { metadataSsmPath: string; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts index bd10efd41e..ab3d850b03 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-up.test.ts @@ -23,9 +23,6 @@ const githubRunnerConfig: CreateGitHubRunnerConfig = { runnerOwner: 'Codertocat', runnerType: 'Org', disableAutoUpdate: true, - ssmTokenPath: '/runner/token', - ssmConfigPath: '/runner/config', - ssmParameterStoreTags: [], }; beforeEach(() => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts index 0111373247..16a38ec54c 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/environment.d.ts @@ -10,6 +10,8 @@ declare global { MICROVM_INGRESS_NETWORK_CONNECTORS: string | undefined; MICROVM_LOG_GROUP: string | undefined; MICROVM_METADATA_SSM_PATH: string; + SSM_CONFIG_PATH: string; + SSM_PARAMETER_STORE_TAGS: string | undefined; SSM_TOKEN_PATH: string; } } From 0100a2211f003420fc65e310a81cd41342044f27 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 22:24:24 +0200 Subject: [PATCH 44/54] chore(scale-runners): remove dummy comment --- .../functions/control-plane/src/scale-runners/github-runner.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index 2a87739e83..b344012149 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -309,7 +309,6 @@ async function createJitConfig( runnerLabels, }); - // Store the JIT config through the selected storage provider. logger.debug('Runner JIT config for ephemeral runner generated.', { instance: runnerId, }); From 38a7e1dbef9d499fcc1a38abfed74a6fbae5f3fc Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 10 Sep 2026 19:33:59 +0200 Subject: [PATCH 45/54] fix(microvm): implement idle scale-down markers --- .../src/control-plane/microvms.test.ts | 6 +++- .../aws/microvm/src/control-plane/microvms.ts | 1 + .../src/control-plane/runner-metadata.test.ts | 34 +++++++++++++++++-- .../src/control-plane/runner-metadata.ts | 19 +++++++++++ .../src/control-plane/scale-down.test.ts | 21 ++++++++++-- .../microvm/src/control-plane/scale-down.ts | 4 ++- 6 files changed, 78 insertions(+), 7 deletions(-) diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts index 3271fd8b98..19698a36c5 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.test.ts @@ -207,7 +207,10 @@ describe('listMicrovmRunners', () => { vi.mocked(listMicrovmRunnerMetadata).mockResolvedValue({ cleanupMicrovmIds: [], metadataById: new Map([ - ['mvm-managed', metadata({ githubRunnerId: '42', bypassRemoval: true })], + [ + 'mvm-managed', + metadata({ githubRunnerId: '42', bypassRemoval: true, idleDetectedAt: '2026-09-10T16:00:00.000Z' }), + ], ['mvm-other', metadata({ microvmId: 'mvm-other', runnerOwner: 'Other' })], ]), }); @@ -231,6 +234,7 @@ describe('listMicrovmRunners', () => { orphan: false, githubRunnerId: '42', bypassRemoval: true, + idleDetectedAt: '2026-09-10T16:00:00.000Z', state: 'RUNNING', }, ]); diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index 3293db55ba..ace08450f3 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -207,6 +207,7 @@ export async function listMicrovmRunners( githubRunnerId: metadata.githubRunnerId, bypassRemoval: metadata.bypassRemoval ?? false, state: item.state, + ...(metadata.idleDetectedAt === undefined ? {} : { idleDetectedAt: metadata.idleDetectedAt }), }); } diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts index 502fc77d05..c73e0f9433 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.test.ts @@ -11,6 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { assertMatchingMicrovmRunnerTokenPath, assertSeparatedMicrovmMetadataPath, + clearMicrovmIdleDetectedAt, createMicrovmRunnerMetadata, deleteMicrovmRunnerJitConfig, deleteMicrovmRunnerSsmState, @@ -19,6 +20,7 @@ import { microvmMetadataParameterName, microvmRunnerJitParameterName, setMicrovmGithubRunnerMetadata, + setMicrovmIdleDetectedAt, setMicrovmOrphan, type MicrovmRunnerMetadata, } from './runner-metadata'; @@ -208,6 +210,7 @@ describe('MicroVM metadata lifecycle', () => { [`${metadataSsmPath}/mvm-1`, JSON.stringify(active)], [`${metadataSsmPath}/mvm-1.github-runner-id`, 'github-42'], [`${metadataSsmPath}/mvm-1.orphan`, 'true'], + [`${metadataSsmPath}/mvm-1.idle-detected-at`, '2026-08-19T11:55:00.000Z'], [`${metadataSsmPath}/mvm-old`, JSON.stringify(expiredInactive)], [`${metadataSsmPath}/mvm-new`, JSON.stringify(unexpiredInactive)], [`${metadataSsmPath}/mvm-invalid`, '{not-json'], @@ -216,7 +219,17 @@ describe('MicroVM metadata lifecycle', () => { await expect(listMicrovmRunnerMetadata(ssmPaths, states([['mvm-1', 'RUNNING']]))).resolves.toEqual({ cleanupMicrovmIds: ['mvm-old', 'mvm-invalid'], - metadataById: new Map([['mvm-1', { ...active, githubRunnerId: 'github-42', orphan: true }]]), + metadataById: new Map([ + [ + 'mvm-1', + { + ...active, + githubRunnerId: 'github-42', + orphan: true, + idleDetectedAt: '2026-08-19T11:55:00.000Z', + }, + ], + ]), }); expect(getParametersByPath).toHaveBeenCalledWith(metadataSsmPath); expect(deleteParameter).not.toHaveBeenCalled(); @@ -397,6 +410,18 @@ describe('MicroVM metadata lifecycle', () => { }); }); + it('updates and clears idle state without a shared read-modify-write record', async () => { + const detectedAt = '2026-08-19T11:55:00.000Z'; + + await setMicrovmIdleDetectedAt(metadataSsmPath, 'mvm-1', detectedAt); + expect(putParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-1.idle-detected-at`, detectedAt, false, { + overwrite: true, + }); + + await clearMicrovmIdleDetectedAt(metadataSsmPath, 'mvm-1'); + expect(deleteParameter).toHaveBeenLastCalledWith(`${metadataSsmPath}/mvm-1.idle-detected-at`); + }); + it('marks cleanup independently and deletes JIT plus metadata while retaining the tombstone until last', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-19T12:00:00.000Z')); @@ -413,6 +438,7 @@ describe('MicroVM metadata lifecycle', () => { `${runnerTokenSsmPath}/mvm-1`, `${metadataSsmPath}/mvm-1.github-runner-id`, `${metadataSsmPath}/mvm-1.orphan`, + `${metadataSsmPath}/mvm-1.idle-detected-at`, `${metadataSsmPath}/mvm-1.tags`, `${metadataSsmPath}/mvm-1`, `${metadataSsmPath}/mvm-1.cleanup-requested-at`, @@ -444,6 +470,7 @@ describe('MicroVM metadata lifecycle', () => { `${runnerTokenSsmPath}/mvm-1`, `${metadataSsmPath}/mvm-1.github-runner-id`, `${metadataSsmPath}/mvm-1.orphan`, + `${metadataSsmPath}/mvm-1.idle-detected-at`, `${metadataSsmPath}/mvm-1.tags`, `${metadataSsmPath}/mvm-1`, `${metadataSsmPath}/mvm-1.cleanup-requested-at`, @@ -532,7 +559,7 @@ describe('MicroVM metadata lifecycle', () => { cleanupMicrovmIds: ['mvm-terminal', 'mvm-recent'], metadataById: new Map(), }); - expect(deleteParameter).toHaveBeenCalledTimes(6); + expect(deleteParameter).toHaveBeenCalledTimes(7); expect(deleteParameter).toHaveBeenCalledWith(`${runnerTokenSsmPath}/mvm-missing`); expect(deleteParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-missing`); expect(deleteParameter).toHaveBeenCalledWith(`${metadataSsmPath}/mvm-missing.tags`); @@ -560,6 +587,7 @@ describe('MicroVM metadata lifecycle', () => { `${runnerTokenSsmPath}/mvm-invalid`, `${metadataSsmPath}/mvm-invalid.github-runner-id`, `${metadataSsmPath}/mvm-invalid.orphan`, + `${metadataSsmPath}/mvm-invalid.idle-detected-at`, `${metadataSsmPath}/mvm-invalid.tags`, `${metadataSsmPath}/mvm-invalid`, `${metadataSsmPath}/mvm-invalid.cleanup-requested-at`, @@ -604,7 +632,7 @@ describe('MicroVM metadata lifecycle', () => { cleanupMicrovmIds: [], metadataById: new Map(), }); - expect(deleteParameter).toHaveBeenCalledTimes(6); + expect(deleteParameter).toHaveBeenCalledTimes(7); }); it('marks a terminal tags-only companion for two-phase cleanup instead of deleting it immediately', async () => { diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts index 0bae7a8d80..e40893de70 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-metadata.ts @@ -26,11 +26,13 @@ const SSM_TAG_VALUE_PATTERN = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u; const MICROVM_ID_PATTERN = /^[A-Za-z0-9_-]+$/; const GITHUB_RUNNER_ID_SUFFIX = '.github-runner-id'; const ORPHAN_SUFFIX = '.orphan'; +const IDLE_DETECTED_AT_SUFFIX = '.idle-detected-at'; const CLEANUP_REQUESTED_AT_SUFFIX = '.cleanup-requested-at'; const TAGS_SUFFIX = '.tags'; const METADATA_COMPANION_SUFFIXES = [ GITHUB_RUNNER_ID_SUFFIX, ORPHAN_SUFFIX, + IDLE_DETECTED_AT_SUFFIX, CLEANUP_REQUESTED_AT_SUFFIX, TAGS_SUFFIX, ] as const; @@ -53,6 +55,7 @@ export interface MicrovmRunnerMetadata { githubRunnerId?: string; imageArn: string; imageVersion?: string; + idleDetectedAt?: string; microvmId: string; orphan?: boolean; runnerOwner: string; @@ -248,6 +251,7 @@ function metadataParameterNames(metadataSsmPath: string, microvmId: string): str return [ `${baseName}${GITHUB_RUNNER_ID_SUFFIX}`, `${baseName}${ORPHAN_SUFFIX}`, + `${baseName}${IDLE_DETECTED_AT_SUFFIX}`, `${baseName}${TAGS_SUFFIX}`, baseName, `${baseName}${CLEANUP_REQUESTED_AT_SUFFIX}`, @@ -466,6 +470,7 @@ export async function listMicrovmRunnerMetadata( ...metadata, githubRunnerId: parameters.get(`${baseName}${GITHUB_RUNNER_ID_SUFFIX}`), orphan: parameters.get(`${baseName}${ORPHAN_SUFFIX}`) === 'true', + idleDetectedAt: parameters.get(`${baseName}${IDLE_DETECTED_AT_SUFFIX}`), }); } @@ -562,6 +567,20 @@ export async function setMicrovmOrphan(metadataSsmPath: string, microvmId: strin }); } +export async function setMicrovmIdleDetectedAt( + metadataSsmPath: string, + microvmId: string, + idleDetectedAt: string, +): Promise { + await putParameter(stateParameterName(metadataSsmPath, microvmId, IDLE_DETECTED_AT_SUFFIX), idleDetectedAt, false, { + overwrite: true, + }); +} + +export async function clearMicrovmIdleDetectedAt(metadataSsmPath: string, microvmId: string): Promise { + await deleteParameterIfPresent(stateParameterName(metadataSsmPath, microvmId, IDLE_DETECTED_AT_SUFFIX)); +} + export async function markMicrovmCleanupPending(metadataSsmPath: string, microvmId: string): Promise { try { await putParameter( diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts index f98eb88628..0c321203d6 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { loadMicrovmProviderConfig } from './config'; import { listMicrovmRunners, microvmBootTimeExceeded, terminateMicrovm } from './microvms'; import { createMicrovmScaleDownProvider } from './scale-down'; -import { setMicrovmOrphan } from './runner-metadata'; +import { clearMicrovmIdleDetectedAt, setMicrovmIdleDetectedAt, setMicrovmOrphan } from './runner-metadata'; vi.mock('./config', () => ({ loadMicrovmProviderConfig: vi.fn() })); vi.mock('./microvms', () => ({ @@ -11,7 +11,11 @@ vi.mock('./microvms', () => ({ microvmBootTimeExceeded: vi.fn(), terminateMicrovm: vi.fn(), })); -vi.mock('./runner-metadata', () => ({ setMicrovmOrphan: vi.fn() })); +vi.mock('./runner-metadata', () => ({ + clearMicrovmIdleDetectedAt: vi.fn(), + setMicrovmIdleDetectedAt: vi.fn(), + setMicrovmOrphan: vi.fn(), +})); const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; const metadataSsmPath = '/github-action-runners/unit-test/microvm-metadata'; @@ -28,6 +32,8 @@ beforeEach(() => { vi.mocked(loadMicrovmProviderConfig).mockReturnValue(providerConfig); vi.mocked(listMicrovmRunners).mockResolvedValue([]); vi.mocked(microvmBootTimeExceeded).mockReturnValue(false); + vi.mocked(clearMicrovmIdleDetectedAt).mockResolvedValue(); + vi.mocked(setMicrovmIdleDetectedAt).mockResolvedValue(); vi.mocked(setMicrovmOrphan).mockResolvedValue(); vi.mocked(terminateMicrovm).mockResolvedValue(); }); @@ -69,6 +75,17 @@ describe('createMicrovmScaleDownProvider', () => { expect(terminateMicrovm).toHaveBeenCalledWith('mvm-1', providerConfig); }); + it('persists and clears the idle-detection timestamp in durable metadata', async () => { + const provider = createMicrovmScaleDownProvider(); + const detectedAt = '2026-09-10T16:00:00.000Z'; + + await provider.markIdle('mvm-1', detectedAt); + await provider.unmarkIdle('mvm-1'); + + expect(setMicrovmIdleDetectedAt).toHaveBeenCalledWith(metadataSsmPath, 'mvm-1', detectedAt); + expect(clearMicrovmIdleDetectedAt).toHaveBeenCalledWith(metadataSsmPath, 'mvm-1'); + }); + it('uses the MicroVM boot-time policy', () => { const provider = createMicrovmScaleDownProvider(); const runner = { id: 'mvm-1', owner: 'Codertocat', type: 'Org' as const }; diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts index 82038711df..23753352c9 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/scale-down.ts @@ -2,7 +2,7 @@ import type { ScaleDownComputeProvider } from '../../../../core'; import { loadMicrovmProviderConfig } from './config'; import type { MicrovmRunnerInfo } from './microvms'; import { listMicrovmRunners, microvmBootTimeExceeded, terminateMicrovm } from './microvms'; -import { setMicrovmOrphan } from './runner-metadata'; +import { clearMicrovmIdleDetectedAt, setMicrovmIdleDetectedAt, setMicrovmOrphan } from './runner-metadata'; export function createMicrovmScaleDownProvider(): Omit { const ssmPaths = () => loadMicrovmProviderConfig(); @@ -16,6 +16,8 @@ export function createMicrovmScaleDownProvider(): Omit await setMicrovmOrphan(ssmPaths().metadataSsmPath, id, true), unmarkOrphan: async (id) => await setMicrovmOrphan(ssmPaths().metadataSsmPath, id, false), + markIdle: async (id, at) => await setMicrovmIdleDetectedAt(ssmPaths().metadataSsmPath, id, at), + unmarkIdle: async (id) => await clearMicrovmIdleDetectedAt(ssmPaths().metadataSsmPath, id), terminate: async (id) => await terminateMicrovm(id, ssmPaths()), }; } From 2c325c1f867cd8f1ac24e459e8be813e29f0fe69 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 16 Sep 2026 20:44:24 +0200 Subject: [PATCH 46/54] fix(microvm): require Terraform 1.5.6 --- modules/compute-providers/aws/microvm/trust-policy/versions.tf | 2 +- modules/compute-providers/aws/microvm/versions.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/compute-providers/aws/microvm/trust-policy/versions.tf b/modules/compute-providers/aws/microvm/trust-policy/versions.tf index 3ef011ea0a..0bedc91fd5 100644 --- a/modules/compute-providers/aws/microvm/trust-policy/versions.tf +++ b/modules/compute-providers/aws/microvm/trust-policy/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.4.0" + required_version = ">= 1.5.6" required_providers { aws = { diff --git a/modules/compute-providers/aws/microvm/versions.tf b/modules/compute-providers/aws/microvm/versions.tf index 3ef011ea0a..0bedc91fd5 100644 --- a/modules/compute-providers/aws/microvm/versions.tf +++ b/modules/compute-providers/aws/microvm/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.4.0" + required_version = ">= 1.5.6" required_providers { aws = { From 7c99086ab7bc55bb8e798f814320c5c8732fa9e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:40:20 +0000 Subject: [PATCH 47/54] docs: auto update terraform docs --- modules/compute-providers/aws/microvm/README.md | 2 +- modules/compute-providers/aws/microvm/trust-policy/README.md | 2 +- modules/multi-runner/README.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/compute-providers/aws/microvm/README.md b/modules/compute-providers/aws/microvm/README.md index 07ecf5d50f..a80d04a9d2 100644 --- a/modules/compute-providers/aws/microvm/README.md +++ b/modules/compute-providers/aws/microvm/README.md @@ -13,7 +13,7 @@ The resolved provider-neutral `runner.iam.role` is passed to Lambda as the Micro | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [terraform](#requirement\_terraform) | >= 1.5.6 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/compute-providers/aws/microvm/trust-policy/README.md b/modules/compute-providers/aws/microvm/trust-policy/README.md index 43302d1055..857ad8013d 100644 --- a/modules/compute-providers/aws/microvm/trust-policy/README.md +++ b/modules/compute-providers/aws/microvm/trust-policy/README.md @@ -7,7 +7,7 @@ This internal submodule builds the MicroVM runner-role trust policy independentl | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [terraform](#requirement\_terraform) | >= 1.5.6 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 809b915290..9c87418a5d 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -160,6 +160,7 @@ module "multi-runner" { | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
})
| `{}` | no | | [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 | +| [experimental\_multi\_runner\_config](#input\_experimental\_multi\_runner\_config) | Experimental per-runner and per-lane overrides. |
map(object({
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 = 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 = 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)
})

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 = 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 = 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)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
})

}))
| `{}` | 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 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 | From 2eeafc3b0d3fb9ffce78f8b7ed500985fa041050 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 17 Sep 2026 15:07:40 +0200 Subject: [PATCH 48/54] chore(pr): limit MicroVM provider change to Lambda code --- .github/workflows/ministack.yml | 2 - .../.terraform.lock.hcl.tofu | 62 ++++----- examples/microvm-foundation/README.md | 2 +- examples/microvm-foundation/variables.tf | 2 +- examples/microvm/.terraform.lock.hcl.tofu | 124 +++++++++--------- .../compute-providers/aws/microvm/README.md | 2 +- .../aws/microvm/tests/provider.tftest.hcl | 16 --- .../aws/microvm/trust-policy/README.md | 2 +- .../aws/microvm/trust-policy/versions.tf | 2 +- .../aws/microvm/validations.tf | 2 +- .../compute-providers/aws/microvm/versions.tf | 2 +- modules/microvm-foundation/README.md | 4 +- modules/microvm-foundation/variables.tf | 13 +- modules/multi-runner/README.md | 1 - modules/multi-runner/validations.tf | 5 +- tests/ministack/microvm-foundation.tfvars | 3 - tests/ministack/microvm.tfvars | 21 --- tests/ministack/run-example.sh | 109 +-------------- 18 files changed, 118 insertions(+), 256 deletions(-) delete mode 100644 tests/ministack/microvm-foundation.tfvars delete mode 100644 tests/ministack/microvm.tfvars diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index b4914914e8..5aec51a53c 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -72,8 +72,6 @@ jobs: - ephemeral - multi-runner - multi-runner-v2 - - microvm-foundation - - microvm - termination-watcher services: ministack: diff --git a/examples/microvm-foundation/.terraform.lock.hcl.tofu b/examples/microvm-foundation/.terraform.lock.hcl.tofu index 0bb2113ed3..045ca37e02 100644 --- a/examples/microvm-foundation/.terraform.lock.hcl.tofu +++ b/examples/microvm-foundation/.terraform.lock.hcl.tofu @@ -2,39 +2,39 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/aws" { - version = "6.64.0" + version = "6.63.0" constraints = ">= 6.61.0" hashes = [ - "h1:/G38+XhC1mBVkmeWdtk/wk7lX2BxviJ2XZ70dpoaKKQ=", - "h1:7BzHdGCBG5usqOIhfBq89dkdUopnSo+qe9qRCKDSRHc=", - "h1:8AgY9Hc5/R5j97WgCcDSlbuKk0pjk3vkp7oz4mtGVY8=", - "h1:DKdOy/0RfYLxpzAXBPWTO5Eusvqx5UoGxiq2J0E6DY4=", - "h1:KSwetpR4S2eUsKmHftt73Cbx72lPWYET4V+Ej05rnkI=", - "h1:MEi5Ecge1Uwx/DRGfdVDzV5Q/soRxKh6dBHxUjGdaDQ=", - "h1:VqjWicgPZW32+YnSe0Lo78qq8/24I8XNV+E9d/lBz/4=", - "h1:WBgbFHdg/3ekWoAH6UeKiwfk6iqLr1f7TX9R/mJUK8M=", - "h1:YisB3zMV5Kh6p5/eVuPAAPEmudD/UqGN4C/V3zRtAq4=", - "h1:bG5dXqR4mSlcebUG+anerOWYDyeaScZJeLSJk0cYBfE=", - "h1:iosW/imG2pc4La7qdeM/rK6ldMXhcU6YVW7tjqwNXtI=", - "h1:nKE1gnLZxIoqukQ1YI9EUdmrQIUeAN4PWb5ecN8U9K8=", - "h1:x0hJO5+On8FaKExr4p2cNJhWsNWFZq1EiDD6CfVwy2E=", - "h1:yPH75sRH+f3aJlJAloOL/BikeZV6/0GP8VQvnJoMRKM=", - "h1:zCWB5ZD98/ZC0a50HTGoC/fTAseh189xxCFEL5Mt7r4=", - "zh:06e09ced9480ae12578122f7a25758a15d8fe684da0f6a0a61b9bc2f4a4918ad", - "zh:2035805f0ed8bf81d493e7a52f22965b3d5d402687a95d1caa8c4b1b348c1264", - "zh:25fe72a3d6a330eab6c8957f9e6bdf297ffdce95fa059fef30b80da764bee6b2", - "zh:49df644d19e39b9947e84609260028687057191ddd941783c0211386ade53040", - "zh:4d8438a5d25f18eb376c8375c70f81afb79d0fc1e63ebb6df1d0e02287964dde", - "zh:5cd9717e819506132126a896e959cd4cf1bb213c033c37777c9d01a593937e2c", - "zh:6955caa4f435373ae870de31bdda85e51c60b68c51a4206df5a21b853bcefe21", - "zh:82a413500c35241745e097797610d2bff57c26e29f34ca711182fdde5c265d13", - "zh:831f78acce42a759a977769b0409387ec13ff64b4f46c24eb7e7662e0f352525", - "zh:88648a159119a0435bf86c6cd1f7482dc43dfa2eb742f9b29053da1ee9fdabd8", - "zh:9fc745d71a2e36dbdae0ee69be70675509e5a9dec1a3c5a9be6007e568d78c07", - "zh:bf6d11d6ed1655f61f70eb2906e5d1f7ff5e78b6539dcfb3116ed6f8960c9e20", - "zh:ca17a6ca363afe930ad3474966d39cb549b7f1e5efdca909972dd26f90eefc89", - "zh:d7e9cc87ada1314e6d8ecc5849385a8f8f45757bd2c145b8657f015c65e5078d", - "zh:eddb4d6d86700788d132ba2a83d306646ccb2a3a0cec0a0ab3e215307c61d8f2", + "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", + "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", + "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", + "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", + "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", + "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", + "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", + "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", + "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", + "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", + "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", + "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", + "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", + "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", + "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", + "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", + "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", + "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", + "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", + "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", + "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", + "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", + "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", + "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", + "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", + "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", + "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", + "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", + "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", + "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", ] } diff --git a/examples/microvm-foundation/README.md b/examples/microvm-foundation/README.md index 4ceb112d29..841552abd5 100644 --- a/examples/microvm-foundation/README.md +++ b/examples/microvm-foundation/README.md @@ -60,7 +60,7 @@ No resources. | [build\_role\_name\_prefix](#input\_build\_role\_name\_prefix) | Name prefix for the Lambda MicroVM build role. | `string` | `"github-actions-runner-microvm-build-"` | no | | [ecr\_repository\_arns](#input\_ecr\_repository\_arns) | Optional private ECR repository ARNs used by the image build. | `set(string)` | `[]` | no | | [image\_name\_prefix](#input\_image\_name\_prefix) | Reserved Lambda MicroVM image-name namespace used by the runtime policy. | `string` | `"github-actions-runner-ubuntu-arm64"` | no | -| [network\_connector\_operator\_role\_name\_prefix](#input\_network\_connector\_operator\_role\_name\_prefix) | Name prefix for the Lambda Network Connector operator role. | `string` | `"github-actions-microvm-net-operator-"` | no | +| [network\_connector\_operator\_role\_name\_prefix](#input\_network\_connector\_operator\_role\_name\_prefix) | Name prefix for the Lambda Network Connector operator role. | `string` | `"github-actions-runner-microvm-network-operator-"` | no | | [network\_connectors](#input\_network\_connectors) | VPC and subnet configuration for regional Lambda MicroVM egress connectors. |
map(object({
name = string
vpc_id = string
subnet_ids = set(string)
network_protocol = optional(string, "IPv4")
}))
| n/a | yes | | [tags](#input\_tags) | Additional tags applied by the foundation module. | `map(string)` |
{
"Component": "microvm-foundation"
}
| no | | [usage\_policy\_name\_prefix](#input\_usage\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM runtime usage policy. | `string` | `"github-actions-runner-microvm-runtime-usage-policy-"` | no | diff --git a/examples/microvm-foundation/variables.tf b/examples/microvm-foundation/variables.tf index aa8d9fb002..4afd3804a5 100644 --- a/examples/microvm-foundation/variables.tf +++ b/examples/microvm-foundation/variables.tf @@ -40,7 +40,7 @@ variable "build_role_name_prefix" { variable "network_connector_operator_role_name_prefix" { type = string description = "Name prefix for the Lambda Network Connector operator role." - default = "github-actions-microvm-net-operator-" + default = "github-actions-runner-microvm-network-operator-" } variable "artifact_bucket_name" { diff --git a/examples/microvm/.terraform.lock.hcl.tofu b/examples/microvm/.terraform.lock.hcl.tofu index 72e73d566d..7aa235531d 100644 --- a/examples/microvm/.terraform.lock.hcl.tofu +++ b/examples/microvm/.terraform.lock.hcl.tofu @@ -2,76 +2,76 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/aws" { - version = "6.64.0" + version = "6.63.0" constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" hashes = [ - "h1:/G38+XhC1mBVkmeWdtk/wk7lX2BxviJ2XZ70dpoaKKQ=", - "h1:7BzHdGCBG5usqOIhfBq89dkdUopnSo+qe9qRCKDSRHc=", - "h1:8AgY9Hc5/R5j97WgCcDSlbuKk0pjk3vkp7oz4mtGVY8=", - "h1:DKdOy/0RfYLxpzAXBPWTO5Eusvqx5UoGxiq2J0E6DY4=", - "h1:KSwetpR4S2eUsKmHftt73Cbx72lPWYET4V+Ej05rnkI=", - "h1:MEi5Ecge1Uwx/DRGfdVDzV5Q/soRxKh6dBHxUjGdaDQ=", - "h1:VqjWicgPZW32+YnSe0Lo78qq8/24I8XNV+E9d/lBz/4=", - "h1:WBgbFHdg/3ekWoAH6UeKiwfk6iqLr1f7TX9R/mJUK8M=", - "h1:YisB3zMV5Kh6p5/eVuPAAPEmudD/UqGN4C/V3zRtAq4=", - "h1:bG5dXqR4mSlcebUG+anerOWYDyeaScZJeLSJk0cYBfE=", - "h1:iosW/imG2pc4La7qdeM/rK6ldMXhcU6YVW7tjqwNXtI=", - "h1:nKE1gnLZxIoqukQ1YI9EUdmrQIUeAN4PWb5ecN8U9K8=", - "h1:x0hJO5+On8FaKExr4p2cNJhWsNWFZq1EiDD6CfVwy2E=", - "h1:yPH75sRH+f3aJlJAloOL/BikeZV6/0GP8VQvnJoMRKM=", - "h1:zCWB5ZD98/ZC0a50HTGoC/fTAseh189xxCFEL5Mt7r4=", - "zh:06e09ced9480ae12578122f7a25758a15d8fe684da0f6a0a61b9bc2f4a4918ad", - "zh:2035805f0ed8bf81d493e7a52f22965b3d5d402687a95d1caa8c4b1b348c1264", - "zh:25fe72a3d6a330eab6c8957f9e6bdf297ffdce95fa059fef30b80da764bee6b2", - "zh:49df644d19e39b9947e84609260028687057191ddd941783c0211386ade53040", - "zh:4d8438a5d25f18eb376c8375c70f81afb79d0fc1e63ebb6df1d0e02287964dde", - "zh:5cd9717e819506132126a896e959cd4cf1bb213c033c37777c9d01a593937e2c", - "zh:6955caa4f435373ae870de31bdda85e51c60b68c51a4206df5a21b853bcefe21", - "zh:82a413500c35241745e097797610d2bff57c26e29f34ca711182fdde5c265d13", - "zh:831f78acce42a759a977769b0409387ec13ff64b4f46c24eb7e7662e0f352525", - "zh:88648a159119a0435bf86c6cd1f7482dc43dfa2eb742f9b29053da1ee9fdabd8", - "zh:9fc745d71a2e36dbdae0ee69be70675509e5a9dec1a3c5a9be6007e568d78c07", - "zh:bf6d11d6ed1655f61f70eb2906e5d1f7ff5e78b6539dcfb3116ed6f8960c9e20", - "zh:ca17a6ca363afe930ad3474966d39cb549b7f1e5efdca909972dd26f90eefc89", - "zh:d7e9cc87ada1314e6d8ecc5849385a8f8f45757bd2c145b8657f015c65e5078d", - "zh:eddb4d6d86700788d132ba2a83d306646ccb2a3a0cec0a0ab3e215307c61d8f2", + "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", + "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", + "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", + "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", + "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", + "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", + "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", + "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", + "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", + "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", + "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", + "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", + "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", + "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", + "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", + "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", + "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", + "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", + "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", + "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", + "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", + "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", + "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", + "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", + "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", + "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", + "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", + "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", + "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", + "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", ] } provider "registry.opentofu.org/hashicorp/null" { - version = "3.3.2" + version = "3.3.1" constraints = "~> 3.0, ~> 3.2" hashes = [ - "h1:1T+00cjQNmRAHAz9xjEBFpf5wRRb0IBuXS/W8ke5BWs=", - "h1:46gmIYe+klib6TlHKSqEkMLjvnzVWiCB2NYA2zR8MX8=", - "h1:7WQ3wjfaeqnXxq+a8cYiYeWUnMTgY1JcuX+z7sZd72s=", - "h1:MVM+vkVtW/YyKfn111pyho0y87I4TekaNMbBLkn0/C8=", - "h1:QBcIbI2Dp4v6Iui37pn4qmw8YeiFLbSWcJuzZVl/65Y=", - "h1:SsVKTUR+vgLaC1YnoDa2fnYpzREcgNgWRcu5x+vwjHA=", - "h1:WUaeuTNn9w6UXZ9cMq4+qZy4ZAr71B9NcUDEXjqfdKs=", - "h1:WtEaA7alasNwEQ4L3+KyQtbkSOPsexzJ4LUZ7PKaycI=", - "h1:WxS7rjYIZ1WQc4GkICch8XrbxoSY8TjUfLbPDo6oEcQ=", - "h1:Ysvc/FPvcwk+iMg7IcLkqZhT/KhtZTYji+UBqMlcTs4=", - "h1:ZLjbXnfVcRvS/DAN3BcNebOsnOcs3Nx6mJpFCj4dZ2c=", - "h1:fAmvQjIGyqdGMc+v/fUEINCyuU4iaKSzsV8PWsOnmAc=", - "h1:jwkbEtf3S7W+Bl4soynNkUHFfK/4I/H74urHY758XVw=", - "h1:qY1sKzlxNTp/dqZR23bM4egmVMRlaQudLlBYraMt1pw=", - "h1:t8H1KNwJQwKE/GqpHeRxOWgMk0Yv35qbBTBzqB/rhr0=", - "zh:09e94b0b7dfc0c6450c247517b5410546039c758e513d89b588af6df70c3d57d", - "zh:0b72497b6fd79a2b04785b64890a565a8cc7b06ded95da05e6dab2f3b8a02d58", - "zh:1c0ee6f81f7bcdec8d568a145a450eb57a6f1cfe5e48943375d1af22ed54151e", - "zh:1c6899b475f035d352af1e7f33dc30beab8b8e3784f8cb55a2cc4a11997fbd66", - "zh:43e57a2a56e9874604501bdebe431bb573fb77d2c5f4d7598ab30727dc0e90ea", - "zh:49cf2f36298a5ac3ac8d80ceb87466e6a99d2c021005bcd9e3a79f2314fd0a13", - "zh:665be40d2c7f3d768b8f39a041371526d4b7b396b4c11e162a1886212da176c9", - "zh:6bb1583d88ddb38b1c6b4624e25ad414ddd8bf65b0dd9c074580847311f83924", - "zh:71d64453bdc795667e9841d7c90e3fef6ff157e0598d51dac4bb1e4583b85407", - "zh:73ac02bc3b680e1ea75aab24ec2a359c8f0a021f73d43b2feae0a899e75a93ad", - "zh:b2b777ee07b910e7345df85321fab6c9a25c33ecb56129758dda8fadaba09fe4", - "zh:bb984d52880749a49e509e3b243804869e1af40ee2d34322a30f5f340f8d8dbd", - "zh:e0724bc083527343b4a4099fd4f95511e49a0e113416cdba58a64446742b68b1", - "zh:e391c14e367cd64d986ddc8d81f2db76d49600ce0521720618ff1ecc0decde7f", - "zh:e95c1af8e8e9967d678cfa7c77ca229c863a68f7c9a85318bf08f26628afe338", + "h1:2wld81FnmHW0WVgy081sIfokCr2+NuatS8yjeLEet7Y=", + "h1:AClQjJ6X22V4qcRgcYSxiXCMmp2pz0G8WVQC7wAx66o=", + "h1:AY3XQbuviNd2X5VhHYEbhNta1m/CG3JD2BKFKhCt1Y4=", + "h1:CUOZUd7H11lsU+4tISlnYIiP5BqnX8IDwFCVqfLJyAg=", + "h1:JIfV0nA/pLWnIFGscvTfuavQCn2NeHxJBeb6UUg/joA=", + "h1:RejAh+nyCwqDGExGln2Kb4Ro5LyHak0eJe0P9g8CHPc=", + "h1:SHOuTZjYymsmy4asuRq6NC3yW+zdVZOOt4f5nrb+EPM=", + "h1:WwPat/gT4gO8GvvKNdSkkXWVD65JppLJfqKOt9HhOqQ=", + "h1:Z3hXVLrOyaRiiLmmL5UCOdcRMguwjN1x5TYNdmBDgls=", + "h1:dd78Ad5HdfPzPts7A9qIxfitXhAriV/qza38fr2ukjk=", + "h1:dyVb++KwDdybzLTE6bf7GZiVQ31iWsgKPWmhTQ8G42k=", + "h1:gD8ZH6WWe+5gg5+y8SpLWGPUDzSxcQ3HKP8IDM/wW3I=", + "h1:juXCww0zRQKFTDZoKqYR0+Sn1lu99oeL6pr0Jh6LWx0=", + "h1:kFAySmtsshyNV7IhIrEdASzVcvwy68eeZCVC66P7yNk=", + "h1:nS5azDopRisB2NInwDx3Hrfg2FdVt8Gw0gTQzC0rd70=", + "zh:164eb061d84e01759f391265865fb31828083d0a06b25f7af7e094cbdb18c799", + "zh:1bb9b669a82b52c0cba2860c71e9ee6699ef302f28cb8ed06f572d39bc6c7c4f", + "zh:1ea9b31a8f29302122c1e8d673693f3ac270336dae560af803cd1117265a469a", + "zh:238bd463cb0154fb935dc331da40c0a9cbe5db9cee615ae5f35ccad5eed7dc41", + "zh:30ef2b7384cf7e20f33fe75754b54cf669d59816f3ad4fc73bfb2b26fb6735e9", + "zh:35b5cded16e4b57c207d03ee0979b14baf486fa520e6edb7a2eecf18f1b85471", + "zh:3dc840d13a50cd215c7540573f27e2b61f739ba90aee5b7c3846079aa0ab5534", + "zh:3f9309a18db608f975d5691fcb47a6e14d77199156a52e9c39dcafe3737f2b07", + "zh:44263a219f7dbd1848b545d080110b4f7d0495e77b71cd3c7a0b5ec52a09accb", + "zh:4dec54aa5f445eeea035bbd4839bcded5e47ecd07cba0e70c5a09e9272cb592f", + "zh:5e8fb319d7c6d6c4566a18b9d0c91580b4901a96acd7fdc476bfc79f074368e2", + "zh:b0e8b6d41834b57fcfbb5ca00da52ccb757e1a95b6a2d546c0dae8bfbeca1cdf", + "zh:bbde4c3a1dcc1718027a61a4cdf661619d17af1b58df1038fe27bcf43c3dc29b", + "zh:c4140fff9f692baf29236557f706f9515f93229413438527d764023a82301da3", + "zh:f8e9d83184e4bbeb97c6f0d569833007c48ba5a7ff334def201df4991d03a962", ] } diff --git a/modules/compute-providers/aws/microvm/README.md b/modules/compute-providers/aws/microvm/README.md index a80d04a9d2..07ecf5d50f 100644 --- a/modules/compute-providers/aws/microvm/README.md +++ b/modules/compute-providers/aws/microvm/README.md @@ -13,7 +13,7 @@ The resolved provider-neutral `runner.iam.role` is passed to Lambda as the Micro | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl b/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl index 81f417e21c..de167845f2 100644 --- a/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl +++ b/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl @@ -546,22 +546,6 @@ run "rejects_invalid_metadata_path" { expect_failures = [terraform_data.validate_config] } -run "rejects_metadata_path_with_duplicate_separators" { - command = plan - - variables { - ssm = { - paths = { - root = "/github-action-runners" - tokens = "tokens" - config = "config//invalid" - } - } - } - - expect_failures = [terraform_data.validate_config] -} - run "rejects_invalid_image_resource_allowlist" { command = plan diff --git a/modules/compute-providers/aws/microvm/trust-policy/README.md b/modules/compute-providers/aws/microvm/trust-policy/README.md index 857ad8013d..43302d1055 100644 --- a/modules/compute-providers/aws/microvm/trust-policy/README.md +++ b/modules/compute-providers/aws/microvm/trust-policy/README.md @@ -7,7 +7,7 @@ This internal submodule builds the MicroVM runner-role trust policy independentl | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/compute-providers/aws/microvm/trust-policy/versions.tf b/modules/compute-providers/aws/microvm/trust-policy/versions.tf index 0bedc91fd5..3ef011ea0a 100644 --- a/modules/compute-providers/aws/microvm/trust-policy/versions.tf +++ b/modules/compute-providers/aws/microvm/trust-policy/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/compute-providers/aws/microvm/validations.tf b/modules/compute-providers/aws/microvm/validations.tf index 17c78b1d81..75c2aeb33b 100644 --- a/modules/compute-providers/aws/microvm/validations.tf +++ b/modules/compute-providers/aws/microvm/validations.tf @@ -58,7 +58,7 @@ resource "terraform_data" "validate_config" { trim(var.ssm.paths.root, "/") != "" && trim(var.ssm.paths.config, "/") != "" && can(regex("^/[A-Za-z0-9_./-]+$", local.microvm_metadata_ssm_path)) && - length(regexall("//", local.microvm_metadata_ssm_path)) == 0 + !strcontains(local.microvm_metadata_ssm_path, "//") ) error_message = "The derived MicroVM metadata Parameter Store path must be an absolute path containing only letters, numbers, dot, underscore, hyphen, and slash." } diff --git a/modules/compute-providers/aws/microvm/versions.tf b/modules/compute-providers/aws/microvm/versions.tf index 0bedc91fd5..3ef011ea0a 100644 --- a/modules/compute-providers/aws/microvm/versions.tf +++ b/modules/compute-providers/aws/microvm/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/microvm-foundation/README.md b/modules/microvm-foundation/README.md index 15747640f8..2c56e7fa8f 100644 --- a/modules/microvm-foundation/README.md +++ b/modules/microvm-foundation/README.md @@ -34,7 +34,7 @@ module "microvm_foundation" { tags = { Environment = "example" } build_policy_name_prefix = "github-actions-runner-microvm-build-policy-" build_role_name_prefix = "github-actions-runner-microvm-build-" - network_connector_operator_role_name_prefix = "github-actions-microvm-net-operator-" + network_connector_operator_role_name_prefix = "github-actions-runner-microvm-network-operator-" usage_policy_name_prefix = "github-actions-runner-microvm-runtime-usage-policy-" image_name_prefix = "github-actions-runner-ubuntu-arm64" @@ -116,7 +116,7 @@ No modules. | [ecr\_repository\_arns](#input\_ecr\_repository\_arns) | Optional regional ECR repository ARNs from which MicroVM image builds can pull runner base images. | `set(string)` | `[]` | no | | [image\_name\_prefix](#input\_image\_name\_prefix) | IAM namespace prefix reserved for externally published Lambda MicroVM image names. This module does not create or enumerate images. | `string` | n/a | yes | | [network\_connector\_operator\_role\_name\_prefix](#input\_network\_connector\_operator\_role\_name\_prefix) | Name prefix for the Lambda Network Connector operator role. | `string` | n/a | yes | -| [network\_connectors](#input\_network\_connectors) | Optional regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity. |
map(object({
name = string
vpc_id = string
subnet_ids = set(string)
network_protocol = optional(string, "IPv4")
}))
| n/a | yes | +| [network\_connectors](#input\_network\_connectors) | Regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity. |
map(object({
name = string
vpc_id = string
subnet_ids = set(string)
network_protocol = optional(string, "IPv4")
}))
| n/a | yes | | [tags](#input\_tags) | A map of module-specific tags to apply to resources. | `map(string)` | n/a | yes | | [usage\_policy\_name\_prefix](#input\_usage\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM runtime usage policy. | `string` | n/a | yes | diff --git a/modules/microvm-foundation/variables.tf b/modules/microvm-foundation/variables.tf index 72c1db000b..81c84d4fd1 100644 --- a/modules/microvm-foundation/variables.tf +++ b/modules/microvm-foundation/variables.tf @@ -43,8 +43,8 @@ variable "network_connector_operator_role_name_prefix" { description = "Name prefix for the Lambda Network Connector operator role." validation { - condition = length(var.network_connector_operator_role_name_prefix) >= 1 && length(var.network_connector_operator_role_name_prefix) <= 38 && can(regex("^[a-zA-Z0-9-_]+-$", var.network_connector_operator_role_name_prefix)) - error_message = "network_connector_operator_role_name_prefix must be 1 to 38 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + condition = length(var.network_connector_operator_role_name_prefix) >= 1 && length(var.network_connector_operator_role_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.network_connector_operator_role_name_prefix)) + error_message = "network_connector_operator_role_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." } } @@ -55,7 +55,7 @@ variable "artifact_bucket_name" { nullable = true validation { - condition = var.artifact_bucket_name == null ? true : length(var.artifact_bucket_name) > 0 + condition = var.artifact_bucket_name == null || length(var.artifact_bucket_name) > 0 error_message = "artifact_bucket_name must be null or a non-empty string." } } @@ -98,7 +98,12 @@ variable "network_connectors" { subnet_ids = set(string) network_protocol = optional(string, "IPv4") })) - description = "Optional regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity." + description = "Regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity." + + validation { + condition = length(var.network_connectors) > 0 + error_message = "network_connectors must contain at least one connector." + } validation { condition = alltrue([ diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 9c87418a5d..809b915290 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -160,7 +160,6 @@ module "multi-runner" { | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
})
| `{}` | no | | [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 | -| [experimental\_multi\_runner\_config](#input\_experimental\_multi\_runner\_config) | Experimental per-runner and per-lane overrides. |
map(object({
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 = 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 = 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)
})

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 = 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 = 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)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
})

}))
| `{}` | 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 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 | diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf index 4be542eb59..5de521c1bb 100644 --- a/modules/multi-runner/validations.tf +++ b/modules/multi-runner/validations.tf @@ -68,10 +68,11 @@ resource "terraform_data" "validate_v2" { precondition { condition = alltrue([ for config in local.resolved_config.multi_runner_config : ( - try(config.orchestration_provider.webhook != null, false) + try(config.orchestration_provider.webhook != null, false) && + try(length(config.orchestration_provider.webhook.matcherConfig.labelMatchers) > 0, false) ) ]) - error_message = "Each experimental v2 runner lane requires a webhook provider." + error_message = "Each experimental v2 runner lane requires a webhook matcher." } precondition { diff --git a/tests/ministack/microvm-foundation.tfvars b/tests/ministack/microvm-foundation.tfvars deleted file mode 100644 index 9d576d77c7..0000000000 --- a/tests/ministack/microvm-foundation.tfvars +++ /dev/null @@ -1,3 +0,0 @@ -aws_region = "eu-west-1" - -network_connectors = {} diff --git a/tests/ministack/microvm.tfvars b/tests/ministack/microvm.tfvars deleted file mode 100644 index b83956b548..0000000000 --- a/tests/ministack/microvm.tfvars +++ /dev/null @@ -1,21 +0,0 @@ -aws_region = "eu-west-1" -environment = "microvm-ministack" - -github_app = { - key_base64_ssm = { - name = "/ministack/microvm/github-app-key" - arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/microvm/github-app-key" - } - id_ssm = { - name = "/ministack/microvm/github-app-id" - arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/microvm/github-app-id" - } - webhook_secret_ssm = { - name = "/ministack/microvm/webhook-secret" - arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/microvm/webhook-secret" - } -} - -lambda_artifact_bucket = "github-actions-runner-microvm-ministack" -microvm_image_arn = "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" -egress_network_connector_arn = "arn:aws:lambda:eu-west-1:000000000000:network-connector:ministack" diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index 232b37ff5f..9ea64e58fc 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -21,17 +21,16 @@ case "$iac_binary" in exit 64 ;; esac -microvm_foundation_default_tfvars=false case "$example" in - base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation | microvm) + base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2) use_tfvars=true ;; termination-watcher) use_tfvars=false ;; *) - echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, microvm-foundation, microvm, termination-watcher" >&2 + echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, termination-watcher" >&2 exit 64 ;; esac @@ -39,7 +38,7 @@ esac case "$action" in init | plan | apply | destroy) ;; *) - echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|microvm-foundation|microvm|termination-watcher} [TFVARS_FILE]" >&2 + echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|termination-watcher} [TFVARS_FILE]" >&2 exit 64 ;; esac @@ -71,9 +70,6 @@ lockfile_existed=false if [ "$use_tfvars" = true ]; then if [ -z "$tfvars_file" ]; then tfvars_file="$script_dir/$example.tfvars" - if [ "$example" = microvm-foundation ]; then - microvm_foundation_default_tfvars=true - fi fi case "$tfvars_file" in @@ -81,21 +77,18 @@ if [ "$use_tfvars" = true ]; then *) tfvars_file="$PWD/$tfvars_file" ;; esac - if [ ! -f "$tfvars_file" ] && [ "$microvm_foundation_default_tfvars" != true ]; then + if [ ! -f "$tfvars_file" ]; then echo "Terraform variables file not found: $tfvars_file" >&2 echo "Pass it as the third argument or set MINISTACK_TFVARS_FILE." >&2 exit 66 fi - fi lambda_fixture_dir="" lambda_created_paths="" ami_created_ids="" ssm_created_names="" -s3_created_buckets="" override_created_paths="" -tfvars_created_paths="" lambda_zip_paths=" $source_root/lambdas/functions/ami-housekeeper/ami-housekeeper.zip $source_root/lambdas/functions/control-plane/runners.zip @@ -113,20 +106,10 @@ cleanup() { rm -f "$override_file" done - for fixture_file in $tfvars_created_paths; do - rm -f "$fixture_file" - done - for name in $ssm_created_names; do ministack_aws ssm delete-parameter --name "$name" >/dev/null 2>&1 || true done - for bucket in $s3_created_buckets; do - ministack_aws s3api delete-object --bucket "$bucket" --key runners.zip >/dev/null 2>&1 || true - ministack_aws s3api delete-object --bucket "$bucket" --key webhook.zip >/dev/null 2>&1 || true - ministack_aws s3api delete-bucket --bucket "$bucket" >/dev/null 2>&1 || true - done - for image_id in $ami_created_ids; do ministack_aws ec2 deregister-image --image-id "$image_id" >/dev/null 2>&1 || true done @@ -234,67 +217,6 @@ create_ssm_fixture() { $name" } -create_microvm_foundation_fixture() { - vpc_id=$(ministack_aws ec2 describe-vpcs \ - --filters Name=is-default,Values=true \ - --query 'Vpcs[0].VpcId' \ - --output text) - subnet_id=$(ministack_aws ec2 describe-subnets \ - --filters "Name=vpc-id,Values=$vpc_id" "Name=state,Values=available" \ - --query 'Subnets[0].SubnetId' \ - --output text) - - case "$vpc_id" in - vpc-[0-9a-f]*) ;; - *) - echo "MiniStack default VPC fixture was not found." >&2 - exit 70 - ;; - esac - - case "$subnet_id" in - subnet-[0-9a-f]*) ;; - *) - echo "MiniStack default subnet fixture was not found." >&2 - exit 70 - ;; - esac - - fixture_tfvars=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-foundation.XXXXXX") - printf '%s\n' \ - "aws_region = \"$AWS_DEFAULT_REGION\"" \ - '' \ - 'network_connectors = {' \ - ' ministack = {' \ - ' name = "ministack"' \ - " vpc_id = \"$vpc_id\"" \ - " subnet_ids = [\"$subnet_id\"]" \ - ' }' \ - '}' > "$fixture_tfvars" - tfvars_created_paths="$tfvars_created_paths -$fixture_tfvars" - tfvars_file="$fixture_tfvars" -} - -create_s3_fixture() { - bucket="$1" - key="$2" - file="$3" - - if ! ministack_aws s3api head-bucket --bucket "$bucket" >/dev/null 2>&1; then - ministack_aws s3api create-bucket \ - --bucket "$bucket" \ - --create-bucket-configuration LocationConstraint="$AWS_DEFAULT_REGION" >/dev/null - s3_created_buckets="$s3_created_buckets -$bucket" - fi - - ministack_aws s3api put-object \ - --bucket "$bucket" \ - --key "$key" \ - --body "$file" >/dev/null -} - create_ami_override() { override_file="$example_root/zz_ministack_ami_override.tf" printf '%s\n' \ @@ -358,10 +280,6 @@ create_ministack_fixtures() { wait_for_ministack - if [ "$microvm_foundation_default_tfvars" = true ]; then - create_microvm_foundation_fixture - fi - lambda_fixture_dir=$(mktemp -d "${TMPDIR:-/tmp}/terraform-aws-github-runner-ministack-lambda.XXXXXX") printf '%s\n' 'exports.handler = async () => ({ statusCode: 200, body: "ministack" });' > "$lambda_fixture_dir/index.js" (CDPATH='' cd -- "$lambda_fixture_dir" && zip -q ministack-lambda.zip index.js) @@ -399,25 +317,6 @@ $lambda_zip" create_ami_fixture "ministack-v2-linux-x64" x86_64 >/dev/null create_ami_fixture "ministack-v2-windows-x64" x86_64 >/dev/null ;; - microvm) - create_ssm_fixture \ - "/ministack/microvm/github-app-key" \ - "test-only" - create_ssm_fixture \ - "/ministack/microvm/github-app-id" \ - "123456" - create_ssm_fixture \ - "/ministack/microvm/webhook-secret" \ - "test-only" - create_s3_fixture \ - "github-actions-runner-microvm-ministack" \ - "runners.zip" \ - "$lambda_fixture_dir/ministack-lambda.zip" - create_s3_fixture \ - "github-actions-runner-microvm-ministack" \ - "webhook.zip" \ - "$lambda_fixture_dir/ministack-lambda.zip" - ;; esac } From 4154e49f9af0c9c2de3219092823ae2d242e0345 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:34:46 +0000 Subject: [PATCH 49/54] docs: auto update terraform docs --- examples/microvm-foundation/README.md | 2 +- modules/microvm-foundation/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/microvm-foundation/README.md b/examples/microvm-foundation/README.md index 841552abd5..f954d0bf10 100644 --- a/examples/microvm-foundation/README.md +++ b/examples/microvm-foundation/README.md @@ -31,7 +31,7 @@ the foundation is available. | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [terraform](#requirement\_terraform) | >= 1.5.6 | | [aws](#requirement\_aws) | >= 6.61 | ## Providers diff --git a/modules/microvm-foundation/README.md b/modules/microvm-foundation/README.md index 2c56e7fa8f..096722b8c4 100644 --- a/modules/microvm-foundation/README.md +++ b/modules/microvm-foundation/README.md @@ -58,7 +58,7 @@ example. Apply it before following the direct Packer build instructions in | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [terraform](#requirement\_terraform) | >= 1.5.6 | | [aws](#requirement\_aws) | >= 6.61 | | [time](#requirement\_time) | >= 0.13 | From 10989f6456e8bee1bf66ad5d2d31a5e0e56e1e94 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:11:01 +0000 Subject: [PATCH 50/54] docs: auto update terraform docs --- examples/microvm/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/microvm/README.md b/examples/microvm/README.md index a124ba8da5..72bcf69260 100644 --- a/examples/microvm/README.md +++ b/examples/microvm/README.md @@ -60,6 +60,7 @@ tables and network ACLs configured by the helper module. |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | +| [random](#requirement\_random) | ~> 3.0 | ## Providers From c7cdfd9068046b0f34f2df0c2ea5bfbe7947d93b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:49:10 +0000 Subject: [PATCH 51/54] docs: auto update terraform docs --- README.md | 3 ++- modules/compute-providers/aws/ec2/README.md | 2 +- modules/multi-runner/README.md | 2 +- modules/runner-config/README.md | 2 +- modules/runners/README.md | 1 + modules/webhook/README.md | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9f5c9041b1..8ba80adff0 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [ami\_housekeeper\_lambda\_timeout](#input\_ami\_housekeeper\_lambda\_timeout) | Time out of the lambda in seconds. | `number` | `300` | no | | [ami\_housekeeper\_lambda\_zip](#input\_ami\_housekeeper\_lambda\_zip) | File location of the lambda zip file. | `string` | `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | -| [aws\_dynamic\_labels\_policy](#input\_aws\_dynamic\_labels\_policy) | Experimental! Can be removed / changed without trigger a major release.
Optional AWS dynamic label policy evaluated by the webhook dispatcher.
Only effective when `enable_dynamic_labels = true`.

Jobs whose provider-specific dynamic labels violate the policy are rejected
with a 202 and a warning is logged. Currently this policy applies to EC2
override labels using the `ghr-ec2-*` prefix.

Evaluation:
1. Keys in `blocked_keys` are always rejected.
2. Keys in `restricted_keys` are allowed only when their value passes the rule.
3. Keys not listed in `blocked_keys` or `restricted_keys` are allowed.

Schema:
- `blocked_keys`: keys to reject outright.
- `restricted_keys`: map of key to value rule:
`{ allowed = [globs], denied = [globs], max = number|string }`.

Keys use the provider dynamic label suffix, not the full label. For example,
use `instance-type` for `ghr-ec2-instance-type`. | `any` | `null` | no | +| [aws\_dynamic\_labels\_policy](#input\_aws\_dynamic\_labels\_policy) | Experimental! Can be removed / changed without trigger a major release.
Optional AWS dynamic label policy evaluated by the webhook dispatcher.
Only effective when `enable_dynamic_labels = true`.

Jobs whose provider-specific dynamic labels violate the policy are rejected
with a 202 and a warning is logged. Currently this policy applies to EC2
override labels using the `ghr-ec2-*` prefix.

Evaluation:
1. If `allowed_keys` is set (non-empty), any key not listed in it is rejected;
everything else in the policy still applies to the keys it does allow.
2. Keys in `blocked_keys` are always rejected. Cannot be used together with
`allowed_keys` — see `docs/configuration.md` for why.
3. Keys in `restricted_keys` are allowed only when their value passes the rule.
4. A key not listed anywhere above is allowed.

Schema:
- `allowed_keys`: only these keys are accepted; every other key is rejected.
- `blocked_keys`: keys to reject outright.
- `restricted_keys`: map of key to value rule:
`{ allowed = [globs], denied = [globs], max = number|string }`.

Keys use the provider dynamic label suffix, not the full label. For example,
use `instance-type` for `ghr-ec2-instance-type`. | `any` | `null` | no | | [aws\_partition](#input\_aws\_partition) | (optiona) partition in the arn namespace to use if not 'aws' | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | | [block\_device\_mappings](#input\_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`. |
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
}
]
| no | @@ -210,6 +210,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [runner\_log\_files](#input\_runner\_log\_files) | (optional) List of logfiles to send to CloudWatch, will only be used if `enable_cloudwatch_agent` is set to true. Object description: `log_group_name`: Name of the log group, `prefix_log_group`: If true, the log group name will be prefixed with `/github-self-hosted-runners/`, `file_path`: path to the log file, `log_stream_name`: name of the log stream, `log_class`: The log class of the log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. Defaults to `STANDARD`. |
list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
}))
| `null` | no | | [runner\_metadata\_options](#input\_runner\_metadata\_options) | Metadata options for the ec2 runner instances. By default, the module uses metadata tags for bootstrapping the runner, only disable `instance_metadata_tags` when using custom scripts for starting the runner. | `map(any)` |
{
"http_endpoint": "enabled",
"http_put_response_hop_limit": 1,
"http_tokens": "required",
"instance_metadata_tags": "enabled"
}
| no | | [runner\_name\_prefix](#input\_runner\_name\_prefix) | The prefix used for the GitHub runner name. The prefix will be used in the default start script to prefix the instance name when register the runner in GitHub. The value is available via an EC2 tag 'ghr:runner\_name\_prefix'. | `string` | `""` | no | +| [runner\_network\_interfaces](#input\_runner\_network\_interfaces) | Advanced network interface configuration for the runner launch template. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#network-interfaces for details. Leave unset (default) to keep using associate\_public\_ipv4\_address for a simple single-interface setup; set this to fully control one or more interfaces. |
list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
}))
| `[]` | no | | [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux, osx, windows). | `string` | `"linux"` | no | | [runner\_placement](#input\_runner\_placement) | The placement options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#placement for details. |
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` | no | | [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | diff --git a/modules/compute-providers/aws/ec2/README.md b/modules/compute-providers/aws/ec2/README.md index ec44a82fa8..6c692445c9 100644 --- a/modules/compute-providers/aws/ec2/README.md +++ b/modules/compute-providers/aws/ec2/README.md @@ -61,7 +61,7 @@ No modules. |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region used by compute-provider resources and policy documents. | `string` | n/a | yes | -| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `on_demand_failover_for_errors`: EC2 errors that trigger on-demand fallback after a Spot failure.
- `scale_errors`: EC2 errors treated as retryable scale-up failures.
- `use_dedicated_host`: Enables the dedicated-host launch path. |
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)
})
| n/a | yes | +| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `network_interfaces`: Advanced network interface configuration for the launch template. Leave empty to keep using `associate_public_ipv4_address` for a simple single-interface setup.
- `on_demand_failover_for_errors`: EC2 errors that trigger on-demand fallback after a Spot failure.
- `scale_errors`: EC2 errors treated as retryable scale-up failures.
- `use_dedicated_host`: Enables the dedicated-host launch path. |
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)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
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)
})
| n/a | yes | | [github](#input\_github) | GitHub Enterprise Server settings available to compute-provider bootstrap data.

- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server. |
object({
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
})
| `{}` | no | | [observability](#input\_observability) | CloudWatch Logs settings available to compute-provider runner log groups.

- `logs.retention_in_days`: Retention period for provider-owned runner log groups.
- `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner log groups.
- `logs.tags`: Shared log-group tags that override module-level `tags`. |
object({
logs = optional(object({
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
tags = optional(map(string), {})
}), {})
})
| `{}` | no | | [prefix](#input\_prefix) | Prefix used to identify resources created for the runner configuration. | `string` | `"github-actions"` | no | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index e1abf62ace..faa3676fcc 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -190,7 +190,7 @@ module "multi-runner" { | [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."
scale\_down\_idle\_confirmation\_seconds: "Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale, so a single not-busy reading is not sufficient evidence a runner is idle. 0 keeps the previous single-reading behaviour."
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)
scale_down_idle_confirmation_seconds = optional(number, 0)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
}), null)
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})

# V2 Contract
tags = optional(map(string), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
organization_runners = optional(bool, false)
}), {})
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
}), null)
queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})
lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
}), {})
}))
| `{}` | 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."
scale\_down\_idle\_confirmation\_seconds: "Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale, so a single not-busy reading is not sufficient evidence a runner is idle. 0 keeps the previous single-reading behaviour."
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: if `allowed_keys` is set, only those keys are accepted; keys in `blocked_keys` are always rejected (cannot be used together with `allowed_keys`); keys in `restricted_keys` are allowed only when their value passes the rule; a key not listed anywhere is allowed. Schema: `{ allowed_keys = [], 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)
scale_down_idle_confirmation_seconds = optional(number, 0)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
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({
allowed_keys = optional(list(string), [])
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
}), null)
queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})
lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
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)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), 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 | diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index ed2e25ea47..70ece73562 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -119,7 +119,7 @@ yarn run dist |------|-------------|------|---------|:--------:| | [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)
microvm = optional(object({
image_arn = string
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), [])
egress_network_connectors = optional(list(string), [])
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
})
| 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.network_interfaces`: Advanced network interface configuration for the launch template. Leave empty to keep using `associate_public_ipv4_address` for a simple single-interface setup.
- `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)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
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)
microvm = optional(object({
image_arn = string
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), [])
egress_network_connectors = optional(list(string), [])
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), 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`: Parameter Store reference for the primary GitHub App private key.
- `app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `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 = map(string)
id = map(string)
additional_apps_manifest = optional(object({
name = string
arn = string
}), null)
additional_app_parameter_arns = optional(list(string), [])
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate independent of the selected runner orchestration provider.

- `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 | diff --git a/modules/runners/README.md b/modules/runners/README.md index 4e4f650bc4..048360449b 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -195,6 +195,7 @@ yarn run dist | [metadata\_options](#input\_metadata\_options) | Metadata options for the ec2 runner instances. By default, the module uses metadata tags for bootstrapping the runner, only disable `instance_metadata_tags` when using custom scripts for starting the runner. | `map(any)` |
{
"http_endpoint": "enabled",
"http_put_response_hop_limit": 1,
"http_tokens": "required",
"instance_metadata_tags": "enabled"
}
| 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 | | [minimum\_running\_time\_in\_minutes](#input\_minimum\_running\_time\_in\_minutes) | The time an ec2 action runner should be running at minimum before terminated if non busy. If not set the default is calculated based on the OS. | `number` | `null` | no | +| [network\_interfaces](#input\_network\_interfaces) | Advanced network interface configuration for the runner launch template. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#network-interfaces for details. Leave unset (default) to keep using associate\_public\_ipv4\_address for a simple single-interface setup; set this to fully control one or more interfaces. |
list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
}))
| `[]` | no | | [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. | `map(string)` |
{
"name_runner": "",
"name_sg": ""
}
| 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 | | [placement](#input\_placement) | The placement options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#placement for details. |
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` | no | diff --git a/modules/webhook/README.md b/modules/webhook/README.md index f6a752da2c..04cdc762d8 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -89,7 +89,7 @@ yarn run dist | [repository\_white\_list](#input\_repository\_white\_list) | List of github repository full names (owner/repo\_name) that will be allowed to use the github app. Leave empty for no filtering. | `list(string)` | `[]` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the environment name will be used. | `string` | `null` | no | | [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | -| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`. |
map(object({
arn = string
id = string
computeProvider = optional(string, "ec2")
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| n/a | yes | +| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `allowed_keys = []`, `blocked_keys = []` (cannot be used together with `allowed_keys`), and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`. |
map(object({
arn = string
id = string
computeProvider = optional(string, "ec2")
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
allowed_keys = optional(list(string), [])
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
| n/a | yes | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
webhook = string
})
| n/a | yes | | [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 | From f78ef1ed7e104a5ee968c9afa699af608e8684cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:21:40 +0000 Subject: [PATCH 52/54] docs: auto update terraform docs --- examples/microvm/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/microvm/README.md b/examples/microvm/README.md index 72bcf69260..a5eb0b48d5 100644 --- a/examples/microvm/README.md +++ b/examples/microvm/README.md @@ -66,7 +66,7 @@ tables and network ACLs configured by the helper module. | Name | Version | |------|---------| -| [random](#provider\_random) | 3.9.0 | +| [random](#provider\_random) | 3.9.1 | ## Modules From b2fc73eb90314e457bdef38b17d1345d510f40d1 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 25 Sep 2026 03:29:10 +0200 Subject: [PATCH 53/54] feat: move code from feat-microvm-lifecycle-hooks --- lambdas/libs/aws-ssm-util/src/index.ts | 2 -- .../microvm/src/control-plane/config.test.ts | 5 ++-- .../aws/microvm/src/control-plane/config.ts | 4 +-- .../aws/microvm/src/control-plane/microvms.ts | 2 +- .../src/control-plane/runner-config.test.ts | 19 +------------- .../src/control-plane/runner-config.ts | 26 +++++-------------- .../storage-providers/aws/ssm/logger.test.ts | 7 +++++ .../libs/storage-providers/aws/ssm/logger.ts | 3 +++ .../aws/ssm/runner-group-cache-store.test.ts | 4 +-- lambdas/package.json | 3 ++- lambdas/yarn.lock | 10 +++++++ 11 files changed, 37 insertions(+), 48 deletions(-) diff --git a/lambdas/libs/aws-ssm-util/src/index.ts b/lambdas/libs/aws-ssm-util/src/index.ts index 9101455de0..37c2496412 100644 --- a/lambdas/libs/aws-ssm-util/src/index.ts +++ b/lambdas/libs/aws-ssm-util/src/index.ts @@ -161,8 +161,6 @@ export async function addParameterTags(parameter_name: string, tags: Tag[]): Pro export const SSM_ADVANCED_TIER_THRESHOLD = 4000; -type PutParameterOptions = { overwrite: true; tags?: never } | { overwrite?: false | undefined; tags?: Tag[] }; - export async function putParameter( parameter_name: string, parameter_value: string, diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts index e58d73093c..84a0c5e0fd 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts @@ -7,10 +7,10 @@ const cleanEnv = process.env; beforeEach(() => { process.env = { ...cleanEnv }; process.env.MICROVM_IMAGE_ARN = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; + process.env.MICROVM_IMAGE_VERSION = '2.0'; process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/microvm-runner'; process.env.MICROVM_METADATA_SSM_PATH = '/github-action-runners/unit-test/microvm-metadata/'; process.env.SSM_TOKEN_PATH = '/github-action-runners/unit-test/token/'; - delete process.env.MICROVM_IMAGE_VERSION; delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS; delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS; delete process.env.MICROVM_LOG_GROUP; @@ -20,7 +20,7 @@ describe('loadMicrovmProviderConfig', () => { it('loads required values and applies optional defaults', () => { expect(loadMicrovmProviderConfig()).toEqual({ imageIdentifier: process.env.MICROVM_IMAGE_ARN, - imageVersion: undefined, + imageVersion: '2.0', executionRoleArn: process.env.MICROVM_EXECUTION_ROLE_ARN, ingressNetworkConnectors: undefined, egressNetworkConnectors: undefined, @@ -46,6 +46,7 @@ describe('loadMicrovmProviderConfig', () => { it.each([ ['MICROVM_IMAGE_ARN', 'MICROVM_IMAGE_ARN'], + ['MICROVM_IMAGE_VERSION', 'MICROVM_IMAGE_VERSION'], ['MICROVM_EXECUTION_ROLE_ARN', 'MICROVM_EXECUTION_ROLE_ARN'], ['MICROVM_METADATA_SSM_PATH', 'MICROVM_METADATA_SSM_PATH'], ['SSM_TOKEN_PATH', 'SSM_TOKEN_PATH'], diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts index b86331967b..9baf16c1d7 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts @@ -4,7 +4,7 @@ export interface MicrovmProviderConfig { egressNetworkConnectors?: string[]; executionRoleArn: string; imageIdentifier: string; - imageVersion?: string; + imageVersion: string; ingressNetworkConnectors?: string[]; logging?: Logging; metadataSsmPath: string; @@ -61,7 +61,7 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig { return { imageIdentifier: requiredEnvironmentValue('MICROVM_IMAGE_ARN', process.env.MICROVM_IMAGE_ARN), - imageVersion: optionalEnvironmentValue(process.env.MICROVM_IMAGE_VERSION), + imageVersion: requiredEnvironmentValue('MICROVM_IMAGE_VERSION', process.env.MICROVM_IMAGE_VERSION), executionRoleArn: requiredEnvironmentValue('MICROVM_EXECUTION_ROLE_ARN', process.env.MICROVM_EXECUTION_ROLE_ARN), ingressNetworkConnectors: parseNetworkConnectors( 'MICROVM_INGRESS_NETWORK_CONNECTORS', diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index ace08450f3..ced9b18a4f 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -88,7 +88,7 @@ export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { runnerTokenSsmPath: '/runner/token', }); }); - - it('requires the image ARN and version to be provided together', () => { - expect(() => - createMicrovmRunHookPayload({ - imageArn, - runnerConfigSsmPath, - runnerTokenSsmPath, - }), - ).toThrow('MicroVM hook payload image ARN and version must be provided together'); - }); - - it('omits image metadata when no explicit image version is selected', () => { - expect(JSON.parse(createMicrovmRunHookPayload({ runnerConfigSsmPath, runnerTokenSsmPath }))).toEqual({ - version: 1, - runnerConfigSsmPath, - runnerTokenSsmPath, - }); - }); }); describe('createMicrovmRunners', () => { @@ -150,6 +132,7 @@ describe('createMicrovmRunners', () => { it('rejects a metadata path that overlaps the JIT token path', async () => { vi.mocked(loadMicrovmProviderConfig).mockReturnValue({ imageIdentifier: imageArn, + imageVersion: '2.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath: '/github-action-runners/unit-test/token/metadata', runnerTokenSsmPath, diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts index 9d7154707b..76c1d9fe8f 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -27,28 +27,18 @@ const MICROVM_METADATA_CONTEXT_TAG_KEYS = new Set([ ]); export interface MicrovmRunHookPayloadV1 { - imageArn?: string; - imageVersion?: string; + imageArn: string; + imageVersion: string; runnerConfigSsmPath: string; runnerTokenSsmPath: string; version: 1; } export function createMicrovmRunHookPayload(payload: Omit): string { - const hasImageArn = payload.imageArn !== undefined; - const hasImageVersion = payload.imageVersion !== undefined; - if (hasImageArn !== hasImageVersion) { - throw new Error('MicroVM hook payload image ARN and version must be provided together'); - } - return JSON.stringify({ version: 1, - ...(hasImageArn - ? { - imageArn: payload.imageArn, - imageVersion: payload.imageVersion, - } - : {}), + imageArn: payload.imageArn, + imageVersion: payload.imageVersion, runnerConfigSsmPath: payload.runnerConfigSsmPath, runnerTokenSsmPath: payload.runnerTokenSsmPath, } satisfies MicrovmRunHookPayloadV1); @@ -140,12 +130,8 @@ export async function createMicrovmRunners( nonRetryableErrorCount: 0, }; const runHookPayload = createMicrovmRunHookPayload({ - ...(config.imageVersion !== undefined - ? { - imageArn: config.imageIdentifier, - imageVersion: config.imageVersion, - } - : {}), + imageArn: config.imageIdentifier, + imageVersion: config.imageVersion, runnerConfigSsmPath: normalizedRunnerConfigPath, runnerTokenSsmPath: normalizedRunnerTokenPath, }); diff --git a/lambdas/libs/storage-providers/aws/ssm/logger.test.ts b/lambdas/libs/storage-providers/aws/ssm/logger.test.ts index 9fcd42af98..e954406b46 100644 --- a/lambdas/libs/storage-providers/aws/ssm/logger.test.ts +++ b/lambdas/libs/storage-providers/aws/ssm/logger.test.ts @@ -28,4 +28,11 @@ describe('AWS SSM storage logger', () => { expect(getErrorNames(error)).toEqual(['GetParameterError', 'ParameterNotFound']); }); + + it('includes the AWS service error type from a wrapped cause', () => { + const cause = Object.assign(new Error('ParameterNotFound'), { __type: 'ParameterNotFound' }); + const error = Object.assign(new Error('wrapped'), { name: 'GetParameterError', cause }); + + expect(getErrorNames(error)).toEqual(['GetParameterError', 'Error', 'ParameterNotFound']); + }); }); diff --git a/lambdas/libs/storage-providers/aws/ssm/logger.ts b/lambdas/libs/storage-providers/aws/ssm/logger.ts index 0e3633eb71..309a5c38ff 100644 --- a/lambdas/libs/storage-providers/aws/ssm/logger.ts +++ b/lambdas/libs/storage-providers/aws/ssm/logger.ts @@ -20,6 +20,9 @@ export function getErrorNames(error: unknown): string[] { if ('name' in current && typeof current.name === 'string') { names.push(current.name); } + if ('__type' in current && typeof current.__type === 'string' && !names.includes(current.__type)) { + names.push(current.__type); + } current = 'cause' in current ? current.cause : undefined; } diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts index 4fc13926d9..20f4373619 100644 --- a/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts @@ -53,7 +53,7 @@ describe('aws_ssm runner group cache store', () => { }); it('returns undefined when ParameterNotFound is wrapped by the SSM provider', async () => { - const cause = Object.assign(new Error('missing'), { name: 'ParameterNotFound' }); + const cause = Object.assign(new Error('ParameterNotFound'), { __type: 'ParameterNotFound' }); getParameterMock.mockRejectedValue( Object.assign(new Error('failed to get parameter'), { name: 'GetParameterError', cause }), ); @@ -64,7 +64,7 @@ describe('aws_ssm runner group cache store', () => { expect.objectContaining({ runnerGroupName: 'Default', parameterName: '/runner/config/runner-group/Default', - errorNames: ['GetParameterError', 'ParameterNotFound'], + errorNames: ['GetParameterError', 'Error', 'ParameterNotFound'], }), ); }); diff --git a/lambdas/package.json b/lambdas/package.json index b223239520..e070302b3a 100644 --- a/lambdas/package.json +++ b/lambdas/package.json @@ -3,7 +3,8 @@ "private": true, "workspaces": [ "functions/*", - "libs/*" + "libs/*", + "services/*" ], "scripts": { "build": "nx run-many --target=build --all", diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 88cf3bab55..c05df8a661 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -201,6 +201,16 @@ __metadata: languageName: unknown linkType: soft +"@aws-github-runner/microvm-lifecycle-hooks@workspace:services/microvm-lifecycle-hooks": + version: 0.0.0-use.local + resolution: "@aws-github-runner/microvm-lifecycle-hooks@workspace:services/microvm-lifecycle-hooks" + dependencies: + "@aws-github-runner/storage-providers": "npm:*" + "@types/node": "npm:^22.19.3" + esbuild: "npm:^0.27.0" + languageName: unknown + linkType: soft + "@aws-github-runner/storage-providers@npm:*, @aws-github-runner/storage-providers@workspace:libs/storage-providers": version: 0.0.0-use.local resolution: "@aws-github-runner/storage-providers@workspace:libs/storage-providers" From 488813a14f03da08c34ce233fd0338299ce6f2bf Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 25 Sep 2026 03:46:56 +0200 Subject: [PATCH 54/54] style: fix formatting issues --- lambdas/libs/aws-ssm-util/src/index.ts | 4 ++-- lambdas/yarn.lock | 10 ---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/lambdas/libs/aws-ssm-util/src/index.ts b/lambdas/libs/aws-ssm-util/src/index.ts index 37c2496412..61d253ef32 100644 --- a/lambdas/libs/aws-ssm-util/src/index.ts +++ b/lambdas/libs/aws-ssm-util/src/index.ts @@ -165,9 +165,9 @@ export async function putParameter( parameter_name: string, parameter_value: string, secure: boolean, - options: { overwrite?: boolean; tags?: Tag[]; ttlSeconds?: number } = {}, + options: { overwrite?: boolean; tags?: Tag[]; ttlSeconds?: number } = {}, ): Promise { - if (options.overwrite!== undefined && options.overwrite && options.tags !== undefined) { + if (options.overwrite !== undefined && options.overwrite && options.tags !== undefined) { throw new Error('SSM parameter tags cannot be supplied when overwriting an existing parameter'); } diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index c05df8a661..88cf3bab55 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -201,16 +201,6 @@ __metadata: languageName: unknown linkType: soft -"@aws-github-runner/microvm-lifecycle-hooks@workspace:services/microvm-lifecycle-hooks": - version: 0.0.0-use.local - resolution: "@aws-github-runner/microvm-lifecycle-hooks@workspace:services/microvm-lifecycle-hooks" - dependencies: - "@aws-github-runner/storage-providers": "npm:*" - "@types/node": "npm:^22.19.3" - esbuild: "npm:^0.27.0" - languageName: unknown - linkType: soft - "@aws-github-runner/storage-providers@npm:*, @aws-github-runner/storage-providers@workspace:libs/storage-providers": version: 0.0.0-use.local resolution: "@aws-github-runner/storage-providers@workspace:libs/storage-providers"