diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index b4edf6f35e..1bfd47c7e5 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -32,7 +32,7 @@ env: AWS_ACCESS_KEY_ID: "000000000000" AWS_DEFAULT_REGION: eu-west-1 AWS_EC2_METADATA_DISABLED: "true" - AWS_ENDPOINT_URL: http://127.0.0.1:4566 + AWS_ENDPOINT_URL: http://localhost:4566 AWS_REGION: eu-west-1 AWS_SECRET_ACCESS_KEY: test-only TF_IN_AUTOMATION: "true" @@ -45,7 +45,6 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false - max-parallel: 4 matrix: example: - base @@ -53,6 +52,7 @@ jobs: - default - ephemeral - multi-runner + - multi-runner-v2 - termination-watcher terraform: - "1.4.0" diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 6d91e10c66..f89bdfe742 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -85,6 +85,9 @@ jobs: "download-lambda", "lambda", "multi-runner", + "compute-providers/aws/microvm", + "compute-providers/aws/microvm/trust-policy", + "microvm-foundation", "runner-binaries-syncer", "runners", "setup-iam-permissions", @@ -155,7 +158,9 @@ jobs: "ephemeral", "termination-watcher", "multi-runner", - "external-managed-ssm-secrets" + "multi-runner-v2", + "external-managed-ssm-secrets", + "microvm-foundation" ] defaults: run: @@ -215,6 +220,8 @@ jobs: module: - modules/runners - modules/multi-runner + - modules/compute-providers/aws/microvm + - modules/compute-providers/aws/microvm/trust-policy defaults: run: working-directory: ${{ matrix.module }} diff --git a/docs/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"] diff --git a/docs/examples/index.md b/docs/examples/index.md index aee1d868b0..b7bdf60811 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -5,8 +5,11 @@ Examples are located in the [examples](https://github.com/github-aws-runners/ter - _[Default](default.md)_: The default example of the module - _[Ephemeral](ephemeral.md)_: Example usages of ephemeral runners based on the default example. - _[Multi Runner](multi-runner.md)_ : Example usage of creating a multi runner which creates multiple runners/ configurations with a single deployment. The examples including: "arm64", "windows", and "ubuntu" runners. +- _[Multi Runner v2](multi-runner-v2.md)_ : Example usage of the experimental v2 multi-runner configuration interface with shared defaults and per-lane overrides. - _[Permissions boundary](permissions-boundary.md)_: Example usages of permissions boundaries. - _[Prebuilt Images](prebuilt.md)_: Example usages of deploying runners with a custom prebuilt image. - _[Termination watcher](termination-watcher.md)_: Example usages of termination watcher. - _[Dedicated Mac Hosts](dedicated-mac-hosts.md)_: Example usage of setting up dedicated hosts for macOS runners. - _[Externally managed SSM secrets](external-managed-ssm-secrets.md)_: Example usage of externally managed SSM secrets for the GitHub App credentials. +- _[MicroVM foundation](microvm-foundation.md)_: Example usage of the regional Lambda MicroVM image-build and Network Connector prerequisites. +- _[Lambda MicroVM](microvm.md)_: Example usage of Linux ARM64 ephemeral runners backed by Lambda MicroVM images. diff --git a/docs/examples/microvm-foundation.md b/docs/examples/microvm-foundation.md new file mode 100644 index 0000000000..b92a148a9c --- /dev/null +++ b/docs/examples/microvm-foundation.md @@ -0,0 +1,3 @@ +# Lambda MicroVM foundation + +--8<-- "examples/microvm-foundation/README.md" diff --git a/docs/examples/multi-runner-v2.md b/docs/examples/multi-runner-v2.md new file mode 100644 index 0000000000..565b601ecb --- /dev/null +++ b/docs/examples/multi-runner-v2.md @@ -0,0 +1 @@ +--8<-- "examples/multi-runner-v2/README.md" diff --git a/docs/modules/public/microvm-foundation.md b/docs/modules/public/microvm-foundation.md new file mode 100644 index 0000000000..17129c131e --- /dev/null +++ b/docs/modules/public/microvm-foundation.md @@ -0,0 +1,3 @@ +# Lambda MicroVM regional foundation + +--8<-- "modules/microvm-foundation/README.md" diff --git a/examples/microvm-foundation/.terraform.lock.hcl b/examples/microvm-foundation/.terraform.lock.hcl new file mode 100644 index 0000000000..78cf3de9bd --- /dev/null +++ b/examples/microvm-foundation/.terraform.lock.hcl @@ -0,0 +1,49 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 6.61.0" + hashes = [ + "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=", + "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=", + "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611", + "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d", + "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a", + "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1", + "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d", + "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c", + "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb", + "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9", + "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd", + "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb", + "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925", + "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04", + "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54", + "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.14.1" + constraints = ">= 0.13.0" + hashes = [ + "h1:GJig5pIwiKDsiF73KLs7vWvDs76/x6DeNSxKrfqlA40=", + "h1:r93SxP++6gUlwCHDQ5OkRmcU8B0yv6ZA9nF0Dh6NJmA=", + "zh:0837ca5b057e5cff94dff7de2fcccafb4abaa33c45de193fe2853e684818a267", + "zh:15a122f72d9e0f34fc5384cc7ec089319641fee5c319748a3aa02fc42f459969", + "zh:342fb83093a280ea7ee0654feae1f5867c62eb8eebc1ab46f9a7ab0b4c878a62", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:99f169834d3370b8341381c6a9c7a8b01fb26027531faa38e6fb49cc23916f68", + "zh:9f482917c7a28cf2436578be7aa9f04f8c811aba8b5949e0223ea987a2757a91", + "zh:ac6b5b8732826f2d1129a8a4a038ac7a7a9ca7b77d2a4608e5703be1a1e2bff0", + "zh:c54782a27d58ce04f6696c6fc0b2cf1e2fba6bed239fb520521a7bce7d7193cb", + "zh:c8d0ddc8f575ecb44f025d54edbfe118e26397fe328a67be62325766f31eb6e7", + "zh:d043b96f204edd2353bf6b2a34e645ffdee2e9634d9bb747331320444810a538", + "zh:e32c288501ca9a6c9d22b52e839dd391fc7083d54ee6b8dc296ce0e6bd3e57ef", + "zh:e47fcc7bb4e9ab5cc522c3b06e4fa9c0bf94b84be8210bc6b1655c44acb2addc", + "zh:f61bf218322bcbe0bd2d56bba738e7fa485e9b54244e13aa12de741b37d450c0", + ] +} diff --git a/examples/microvm-foundation/README.md b/examples/microvm-foundation/README.md new file mode 100644 index 0000000000..bdc531bc12 --- /dev/null +++ b/examples/microvm-foundation/README.md @@ -0,0 +1,77 @@ +# MicroVM foundation example + +This example creates the regional dependencies required by the Lambda MicroVM +image build and runner runtime using the reusable module in this repository. + +Set real VPC and subnet IDs in `terraform.tfvars` (copy +`terraform.tfvars.example`). The module validates that every selected subnet +belongs to its configured VPC. + +```bash +terraform init +terraform apply +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: + +- `artifact_bucket_name` -> `MICROVM_ARTIFACT_BUCKET` +- `build_role_arn` -> `MICROVM_BUILD_ROLE_ARN` +- `connector_arns.cicd` -> `MICROVM_EGRESS_NETWORK_CONNECTOR_ARN` +- `usage_policy_arn` -> attach to the control-plane role used by the runner example + +The foundation module owns regional storage, build IAM, Network Connectors, +and the reusable runtime policy. It does not publish an image or create the +runner control plane; those steps remain explicit and can be performed after +the foundation is available. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [aws](#requirement\_aws) | >= 6.61 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [microvm\_foundation](#module\_microvm\_foundation) | ../../modules/microvm-foundation | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [artifact\_bucket\_name](#input\_artifact\_bucket\_name) | Optional globally unique S3 bucket name. When null, AWS generates the bucket name. | `string` | `null` | no | +| [artifact\_retention\_days](#input\_artifact\_retention\_days) | Number of days to retain current and noncurrent build artifacts. | `number` | `30` | no | +| [aws\_profile](#input\_aws\_profile) | Optional local AWS CLI profile. Leave null when credentials are provided by the environment or role. | `string` | `null` | no | +| [aws\_region](#input\_aws\_region) | AWS region in which to create the MicroVM foundation. | `string` | `"eu-west-1"` | no | +| [build\_policy\_name\_prefix](#input\_build\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM build policy. | `string` | `"github-actions-runner-microvm-build-policy-"` | no | +| [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\_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 |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [artifact\_bucket\_name](#output\_artifact\_bucket\_name) | S3 bucket to pass to the MicroVM image build. |
+| [artifact\_prefix](#output\_artifact\_prefix) | S3 prefix used for MicroVM build artifacts. |
+| [build\_role\_arn](#output\_build\_role\_arn) | Lambda build role ARN to pass to the image build. |
+| [connector\_arns](#output\_connector\_arns) | Regional Network Connector ARNs keyed by configuration name. |
+| [usage\_policy\_arn](#output\_usage\_policy\_arn) | Unattached runtime usage policy for the runner control-plane role. |
+
diff --git a/examples/microvm-foundation/main.tf b/examples/microvm-foundation/main.tf
new file mode 100644
index 0000000000..27ed22c816
--- /dev/null
+++ b/examples/microvm-foundation/main.tf
@@ -0,0 +1,15 @@
+module "microvm_foundation" {
+ source = "../../modules/microvm-foundation"
+
+ aws_region = var.aws_region
+ tags = var.tags
+ build_policy_name_prefix = var.build_policy_name_prefix
+ build_role_name_prefix = var.build_role_name_prefix
+ network_connector_operator_role_name_prefix = var.network_connector_operator_role_name_prefix
+ usage_policy_name_prefix = var.usage_policy_name_prefix
+ artifact_bucket_name = var.artifact_bucket_name
+ artifact_retention_days = var.artifact_retention_days
+ image_name_prefix = var.image_name_prefix
+ ecr_repository_arns = var.ecr_repository_arns
+ network_connectors = var.network_connectors
+}
diff --git a/examples/microvm-foundation/outputs.tf b/examples/microvm-foundation/outputs.tf
new file mode 100644
index 0000000000..709d43f933
--- /dev/null
+++ b/examples/microvm-foundation/outputs.tf
@@ -0,0 +1,24 @@
+output "artifact_bucket_name" {
+ description = "S3 bucket to pass to the MicroVM image build."
+ value = module.microvm_foundation.artifact_bucket_name
+}
+
+output "artifact_prefix" {
+ description = "S3 prefix used for MicroVM build artifacts."
+ value = module.microvm_foundation.artifact_prefix
+}
+
+output "build_role_arn" {
+ description = "Lambda build role ARN to pass to the image build."
+ value = module.microvm_foundation.build_role_arn
+}
+
+output "connector_arns" {
+ description = "Regional Network Connector ARNs keyed by configuration name."
+ value = module.microvm_foundation.connector_arns
+}
+
+output "usage_policy_arn" {
+ description = "Unattached runtime usage policy for the runner control-plane role."
+ value = module.microvm_foundation.usage_policy_arn
+}
diff --git a/examples/microvm-foundation/providers.tf b/examples/microvm-foundation/providers.tf
new file mode 100644
index 0000000000..9e8a8a7627
--- /dev/null
+++ b/examples/microvm-foundation/providers.tf
@@ -0,0 +1,4 @@
+provider "aws" {
+ region = var.aws_region
+ profile = var.aws_profile
+}
diff --git a/examples/microvm-foundation/terraform.tfvars.example b/examples/microvm-foundation/terraform.tfvars.example
new file mode 100644
index 0000000000..ef864c384c
--- /dev/null
+++ b/examples/microvm-foundation/terraform.tfvars.example
@@ -0,0 +1,15 @@
+aws_region = "eu-west-1"
+
+network_connectors = {
+ cicd = {
+ name = "github-actions-runner-egress"
+ vpc_id = "vpc-0123456789abcdef0"
+ subnet_ids = ["subnet-0123456789abcdef0", "subnet-0fedcba9876543210"]
+ }
+}
+
+# Add the private ECR repository that contains the regional Ubuntu base image
+# when the image build pulls from ECR.
+# ecr_repository_arns = [
+# "arn:aws:ecr:eu-west-1:123456789012:repository/actions-runner-base-image",
+# ]
diff --git a/examples/microvm-foundation/variables.tf b/examples/microvm-foundation/variables.tf
new file mode 100644
index 0000000000..aa8d9fb002
--- /dev/null
+++ b/examples/microvm-foundation/variables.tf
@@ -0,0 +1,79 @@
+variable "aws_profile" {
+ type = string
+ description = "Optional local AWS CLI profile. Leave null when credentials are provided by the environment or role."
+ default = null
+ nullable = true
+}
+
+variable "aws_region" {
+ type = string
+ description = "AWS region in which to create the MicroVM foundation."
+ default = "eu-west-1"
+}
+
+variable "tags" {
+ type = map(string)
+ description = "Additional tags applied by the foundation module."
+ default = {
+ Component = "microvm-foundation"
+ }
+}
+
+variable "build_policy_name_prefix" {
+ type = string
+ description = "Name prefix for the Lambda MicroVM build policy."
+ default = "github-actions-runner-microvm-build-policy-"
+}
+
+variable "usage_policy_name_prefix" {
+ type = string
+ description = "Name prefix for the Lambda MicroVM runtime usage policy."
+ default = "github-actions-runner-microvm-runtime-usage-policy-"
+}
+
+variable "build_role_name_prefix" {
+ type = string
+ description = "Name prefix for the Lambda MicroVM build role."
+ default = "github-actions-runner-microvm-build-"
+}
+
+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-"
+}
+
+variable "artifact_bucket_name" {
+ type = string
+ description = "Optional globally unique S3 bucket name. When null, AWS generates the bucket name."
+ default = null
+ nullable = true
+}
+
+variable "artifact_retention_days" {
+ type = number
+ description = "Number of days to retain current and noncurrent build artifacts."
+ default = 30
+}
+
+variable "image_name_prefix" {
+ type = string
+ description = "Reserved Lambda MicroVM image-name namespace used by the runtime policy."
+ default = "github-actions-runner-ubuntu-arm64"
+}
+
+variable "ecr_repository_arns" {
+ type = set(string)
+ description = "Optional private ECR repository ARNs used by the image build."
+ default = []
+}
+
+variable "network_connectors" {
+ type = map(object({
+ name = string
+ vpc_id = string
+ subnet_ids = set(string)
+ network_protocol = optional(string, "IPv4")
+ }))
+ description = "VPC and subnet configuration for regional Lambda MicroVM egress connectors."
+}
diff --git a/examples/microvm-foundation/versions.tf b/examples/microvm-foundation/versions.tf
new file mode 100644
index 0000000000..e72a26b153
--- /dev/null
+++ b/examples/microvm-foundation/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.4.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.61"
+ }
+ }
+}
diff --git a/examples/multi-runner-v2/.terraform.lock.hcl b/examples/multi-runner-v2/.terraform.lock.hcl
new file mode 100644
index 0000000000..c96d2b19bf
--- /dev/null
+++ b/examples/multi-runner-v2/.terraform.lock.hcl
@@ -0,0 +1,93 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "6.63.0"
+ constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0"
+ hashes = [
+ "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=",
+ "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=",
+ "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611",
+ "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d",
+ "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a",
+ "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1",
+ "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d",
+ "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c",
+ "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb",
+ "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9",
+ "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd",
+ "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb",
+ "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925",
+ "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04",
+ "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54",
+ "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/local" {
+ version = "2.9.0"
+ constraints = "~> 2.0"
+ hashes = [
+ "h1:9rBZCMNpxKwMlRbWH2QpwD3kqUCAejdOZQ/aiiDObXQ=",
+ "h1:m24fjcInWvTVZ1XSo2MaNuKPe+X/gfG8SIi09rA7a7M=",
+ "zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0",
+ "zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64",
+ "zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90",
+ "zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c",
+ "zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8",
+ "zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8",
+ "zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b",
+ "zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9",
+ "zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195",
+ "zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3",
+ "zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/null" {
+ version = "3.3.1"
+ constraints = "~> 3.0, ~> 3.2"
+ hashes = [
+ "h1:TuxJq10DVnRP7c5HBZPyyvQGcckNVfijyU1eXEu5e4M=",
+ "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=",
+ "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44",
+ "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451",
+ "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05",
+ "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3",
+ "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7",
+ "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1",
+ "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be",
+ "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891",
+ "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5",
+ "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610",
+ "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+ version = "3.9.0"
+ constraints = "~> 3.0"
+ hashes = [
+ "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=",
+ "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=",
+ "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1",
+ "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea",
+ "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f",
+ "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0",
+ "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61",
+ "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc",
+ "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e",
+ "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef",
+ "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b",
+ "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257",
+ "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04",
+ ]
+}
diff --git a/examples/multi-runner-v2/README.md b/examples/multi-runner-v2/README.md
new file mode 100644
index 0000000000..f8cea507ec
--- /dev/null
+++ b/examples/multi-runner-v2/README.md
@@ -0,0 +1,74 @@
+# Multi-runner v2 example
+
+This example demonstrates the experimental multi-runner v2 interface. Shared
+defaults are configured with `global_config*` variables, while
+each runner lane uses `multi_runner_config` for its matcher,
+runner lifecycle, and compute-provider settings.
+
+The example creates three lanes from one deployment:
+
+- Linux ARM64 Amazon Linux runners.
+- Ephemeral Linux x64 Amazon Linux runners with job retry enabled.
+- Windows x64 Server Core 2022 runners.
+
+The v2 interface keeps provider-owned settings inside the selected provider
+configuration. For example, VPC and subnet settings are under
+`global_config_compute_provider.aws.ec2`, while the per-lane
+instance types and AMI filter are under each lane's compute provider block.
+
+Configure the GitHub App variables before applying:
+
+```bash
+terraform init
+terraform apply \
+ -var='github_app={id="123456",key_base64="..."}'
+```
+
+The `github_app` value is sensitive and should be supplied through a secure
+variable source in real deployments rather than committed to configuration.
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.4.0 |
+| [aws](#requirement\_aws) | >= 6.33 |
+| [local](#requirement\_local) | ~> 2.0 |
+| [random](#requirement\_random) | ~> 3.0 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [random](#provider\_random) | 3.9.0 |
+
+## Modules
+
+| Name | Source | Version |
+|------|--------|---------|
+| [base](#module\_base) | ../base | n/a |
+| [runners](#module\_runners) | ../../modules/multi-runner | n/a |
+| [webhook\_github\_app](#module\_webhook\_github\_app) | ../../modules/webhook-github-app | n/a |
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [random_id.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [aws\_region](#input\_aws\_region) | AWS region to deploy to. | `string` | `"eu-west-1"` | no |
+| [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no |
+| [github\_app](#input\_github\_app) | GitHub App ID and base64-encoded private key. | object({
id = string
key_base64 = string
}) | n/a | yes |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [webhook\_endpoint](#output\_webhook\_endpoint) | n/a |
+| [webhook\_secret](#output\_webhook\_secret) | n/a |
+
diff --git a/examples/multi-runner-v2/main.tf b/examples/multi-runner-v2/main.tf
new file mode 100644
index 0000000000..ca740ca078
--- /dev/null
+++ b/examples/multi-runner-v2/main.tf
@@ -0,0 +1,173 @@
+locals {
+ environment = var.environment != null ? var.environment : "multi-runner-v2"
+ aws_region = var.aws_region
+}
+
+resource "random_id" "random" {
+ byte_length = 20
+}
+
+module "base" {
+ source = "../base"
+
+ prefix = local.environment
+ aws_region = local.aws_region
+}
+
+module "runners" {
+ source = "../../modules/multi-runner"
+
+ prefix = local.environment
+ aws_region = local.aws_region
+
+ global_config = {
+ tags = {
+ Example = local.environment
+ Project = "ProjectX"
+ }
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ extra_labels = ["v2"]
+ }
+ }
+
+ global_config_github = {
+ app = {
+ key_base64 = var.github_app.key_base64
+ id = var.github_app.id
+ webhook_secret = random_id.random.hex
+ }
+ }
+
+ global_config_lambda = {
+ architecture = "arm64"
+ }
+
+ global_config_orchestration_provider = {
+ webhook = {
+ eventbridge = {
+ enabled = true
+ accept_events = ["workflow_job"]
+ }
+ }
+ }
+
+ global_config_compute_provider = {
+ aws = {
+ ec2 = {
+ vpc_id = module.base.vpc.vpc_id
+ subnet_ids = module.base.vpc.private_subnets
+ ssm_enabled = true
+ runner_binaries = {
+ enabled = true
+ }
+ }
+ }
+ }
+
+ multi_runner_config = {
+ linux-arm64 = {
+ runner = {
+ architecture = "arm64"
+ name_prefix = "amazon-arm64-"
+ extra_labels = ["amazon"]
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ maximum_count = 1
+ }
+ matcherConfig = {
+ exactMatch = true
+ labelMatchers = [["self-hosted", "linux", "arm64", "amazon"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["t4g.large", "c6g.large"]
+ }
+ }
+ }
+ }
+
+ linux-x64 = {
+ runner = {
+ name_prefix = "amazon-x64-"
+ extra_labels = ["amazon"]
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ ephemeral = true
+ maximum_count = 1
+ }
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "linux", "x64", "amazon"]]
+ exactMatch = false
+ priority = 1
+ }
+ queue = {
+ delay_webhook_event = 0
+ }
+ job_retry = {
+ enabled = true
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5a.large", "m5ad.large"]
+ }
+ }
+ }
+ }
+
+ windows-x64 = {
+ runner = {
+ os = "windows"
+ name_prefix = "windows-x64-"
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ boot_time_in_minutes = 20
+ maximum_count = 1
+ }
+ matcherConfig = {
+ exactMatch = true
+ labelMatchers = [["self-hosted", "windows", "x64", "servercore-2022"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large", "c5.large"]
+ ami = {
+ filter = {
+ name = ["Windows_Server-2022-English-Full-ECS_Optimized-*"]
+ state = ["available"]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+module "webhook_github_app" {
+ source = "../../modules/webhook-github-app"
+ depends_on = [module.runners]
+
+ github_app = {
+ key_base64 = var.github_app.key_base64
+ id = var.github_app.id
+ webhook_secret = random_id.random.hex
+ }
+ webhook_endpoint = module.runners.webhook.endpoint
+}
diff --git a/examples/multi-runner-v2/outputs.tf b/examples/multi-runner-v2/outputs.tf
new file mode 100644
index 0000000000..1feaf2e671
--- /dev/null
+++ b/examples/multi-runner-v2/outputs.tf
@@ -0,0 +1,8 @@
+output "webhook_endpoint" {
+ value = module.runners.webhook.endpoint
+}
+
+output "webhook_secret" {
+ sensitive = true
+ value = random_id.random.hex
+}
diff --git a/examples/multi-runner-v2/providers.tf b/examples/multi-runner-v2/providers.tf
new file mode 100644
index 0000000000..eca2fe96a7
--- /dev/null
+++ b/examples/multi-runner-v2/providers.tf
@@ -0,0 +1,9 @@
+provider "aws" {
+ region = local.aws_region
+
+ default_tags {
+ tags = {
+ Example = local.environment
+ }
+ }
+}
diff --git a/examples/multi-runner-v2/variables.tf b/examples/multi-runner-v2/variables.tf
new file mode 100644
index 0000000000..2a7f7eda54
--- /dev/null
+++ b/examples/multi-runner-v2/variables.tf
@@ -0,0 +1,23 @@
+variable "github_app" {
+ description = "GitHub App ID and base64-encoded private key."
+
+ type = object({
+ id = string
+ key_base64 = string
+ })
+ sensitive = true
+}
+
+variable "environment" {
+ description = "Environment name, used as prefix."
+
+ type = string
+ default = null
+}
+
+variable "aws_region" {
+ description = "AWS region to deploy to."
+
+ type = string
+ default = "eu-west-1"
+}
diff --git a/examples/multi-runner-v2/versions.tf b/examples/multi-runner-v2/versions.tf
new file mode 100644
index 0000000000..1dfb3e5774
--- /dev/null
+++ b/examples/multi-runner-v2/versions.tf
@@ -0,0 +1,17 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.33"
+ }
+ local = {
+ source = "hashicorp/local"
+ version = "~> 2.0"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.0"
+ }
+ }
+ required_version = ">= 1.4.0"
+}
diff --git a/examples/multi-runner/README.md b/examples/multi-runner/README.md
index 5bb10f7248..960030c099 100644
--- a/examples/multi-runner/README.md
+++ b/examples/multi-runner/README.md
@@ -56,7 +56,7 @@ terraform output -raw webhook_secret
| Name | Version |
|------|---------|
-| [terraform](#requirement\_terraform) | >= 1.3.0 |
+| [terraform](#requirement\_terraform) | >= 1.4.0 |
| [aws](#requirement\_aws) | >= 6.33 |
| [local](#requirement\_local) | ~> 2.0 |
| [random](#requirement\_random) | ~> 3.0 |
diff --git a/examples/multi-runner/versions.tf b/examples/multi-runner/versions.tf
index 666b978aac..1dfb3e5774 100644
--- a/examples/multi-runner/versions.tf
+++ b/examples/multi-runner/versions.tf
@@ -13,5 +13,5 @@ terraform {
version = "~> 3.0"
}
}
- required_version = ">= 1.3.0"
+ required_version = ">= 1.4.0"
}
diff --git a/mkdocs.yaml b/mkdocs.yaml
index 849b9a53dc..0188c772eb 100644
--- a/mkdocs.yaml
+++ b/mkdocs.yaml
@@ -67,6 +67,7 @@ nav:
- AMI Housekeeper: modules/public/ami-housekeeper.md
- Lambda Downloader: modules/public/download-lambda.md
- Setup IAM permissions: modules/public/setup-iam-permissions.md
+ - MicroVM foundation: modules/public/microvm-foundation.md
- Submodules (internal):
- Runners: modules/internal/runners.md
- Syncer: modules/internal/runner-binaries-syncer.md
@@ -77,6 +78,7 @@ nav:
- Overview: examples/index.md
- Default: examples/default.md
- Multi Runner: examples/multi-runner.md
+ - Multi Runner v2: examples/multi-runner-v2.md
- Ephemeral: examples/ephemeral.md
- External managed secrets: examples/external-managed-ssm-secrets.md
- Custom AMI: examples/prebuilt.md
diff --git a/modules/compute-providers/aws/microvm/README.md b/modules/compute-providers/aws/microvm/README.md
new file mode 100644
index 0000000000..07ecf5d50f
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/README.md
@@ -0,0 +1,70 @@
+# AWS Lambda MicroVM runner provider
+
+This internal module implements the AWS Lambda MicroVM compute provider used by `runner-config`. It returns provider-specific Lambda environment variables, control-plane IAM policy fragments, selected image metadata, native runtime and optional CloudWatch-agent log groups, and collected-file definitions through the common provider contract; the parent owns the runner role, Lambda resources, queues, schedules, and Parameter Store lifecycle.
+
+Select it with the `compute_provider.aws.microvm` leaf. The Terraform dispatch key is `aws_microvm`, while the runtime `COMPUTE_PROVIDER_TYPE` remains `microvm` for compatibility with the control-plane Lambda. MicroVM lanes require Linux on ARM64 and ephemeral webhook orchestration with just-in-time configuration enabled.
+
+MicroVM runners use the provider's fixed 28,800-second (8-hour) lifetime; this is not a Terraform input.
+
+The resolved provider-neutral `runner.iam.role` is passed to Lambda as the MicroVM execution role. The provider creates `/github-self-hosted-runners/object({
image_arn = string
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), [])
egress_network_connectors = optional(list(string), [])
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}) | n/a | yes |
+| [github](#input\_github) | GitHub Enterprise Server settings available to compute-provider bootstrap data.object({
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
}) | `{}` | no |
+| [observability](#input\_observability) | Provider-neutral observability settings applied to the provider-managed MicroVM runtime log group.object({
logs = optional(object({
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
}) | `{}` | no |
+| [prefix](#input\_prefix) | Prefix used to identify resources created for the runner configuration. | `string` | `"github-actions"` | no |
+| [runner](#input\_runner) | Resolved runner settings consumed by the Lambda MicroVM compute provider.object({
os = optional(string, "linux")
architecture = optional(string, "arm64")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = object({
role = object({
arn = string
name = string
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
}) | n/a | yes |
+| [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.object({
paths = object({
root = string
tokens = string
config = string
})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
}) | n/a | yes |
+| [tags](#input\_tags) | Base tags available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [environment\_variables](#output\_environment\_variables) | Provider-specific Lambda environment variable fragments consumed by runner-config. |
+| [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-config. |
+| [provider](#output\_provider) | Nested Lambda MicroVM compute-provider contract consumed by runner-config. |
+| [resources](#output\_resources) | Provider-specific MicroVM resources exposed by runner-config. |
+
diff --git a/modules/compute-providers/aws/microvm/control-plane.tf b/modules/compute-providers/aws/microvm/control-plane.tf
new file mode 100644
index 0000000000..832a90df04
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/control-plane.tf
@@ -0,0 +1,118 @@
+data "aws_iam_policy_document" "scale_up" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "lambda:ListMicrovms",
+ "lambda:PassNetworkConnector",
+ ]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "lambda:RunMicrovm",
+ "lambda:TerminateMicrovm",
+ ]
+ resources = local.microvm_image_resource_arns
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:AddTagsToResource",
+ "ssm:DeleteParameter",
+ "ssm:PutParameter",
+ ]
+ resources = [local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParametersByPath"]
+ resources = [local.microvm_metadata_path_arn, local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameters"]
+ resources = [local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["iam:PassRole"]
+ resources = [var.runner.iam.role.arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:DeleteParameter"]
+ resources = [local.runner_token_path_arn]
+ }
+}
+
+data "aws_iam_policy_document" "scale_down" {
+ statement {
+ effect = "Allow"
+ actions = ["lambda:ListMicrovms"]
+ resources = ["*"]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["lambda:TerminateMicrovm"]
+ resources = local.microvm_image_resource_arns
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:DeleteParameter",
+ "ssm:PutParameter",
+ ]
+ resources = [local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParametersByPath"]
+ resources = [local.microvm_metadata_path_arn, local.microvm_metadata_parameter_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:DeleteParameter"]
+ resources = [local.runner_token_path_arn]
+ }
+}
+
+locals {
+ microvm_metadata_ssm_path = "${local.ssm_config_ssm_path}/microvm-metadata"
+ microvm_metadata_path_arn = "${local.ssm_parameter_arn_prefix}${local.microvm_metadata_ssm_path}"
+ microvm_metadata_parameter_arn = "${local.microvm_metadata_path_arn}/*"
+ microvm_image_resource_arns = coalesce(
+ var.config.iam.resource_arns.images,
+ [var.config.image_arn],
+ )
+ runner_jit_ssm_path = "/${trim(var.ssm.paths.root, "/")}/${trim(var.ssm.paths.tokens, "/")}"
+
+ microvm_custom_environment_variables = {
+ for key, value in var.config.environment_variables : key => value
+ if !contains(["MICROVM_METADATA_TAGS", "MICROVM_RUNNER_CONFIG_SSM_ARN"], key)
+ }
+ microvm_environment_variables = merge(local.microvm_custom_environment_variables, {
+ MICROVM_EGRESS_NETWORK_CONNECTORS = length(var.config.egress_network_connectors) == 0 ? "" : jsonencode(var.config.egress_network_connectors)
+ MICROVM_EXECUTION_ROLE_ARN = var.runner.iam.role.arn
+ MICROVM_IMAGE_ARN = var.config.image_arn
+ MICROVM_IMAGE_VERSION = var.config.image_version == null ? "" : var.config.image_version
+ MICROVM_INGRESS_NETWORK_CONNECTORS = length(var.config.ingress_network_connectors) == 0 ? "" : jsonencode(var.config.ingress_network_connectors)
+ MICROVM_LOG_GROUP = aws_cloudwatch_log_group.runtime.name
+ MICROVM_METADATA_SSM_PATH = local.microvm_metadata_ssm_path
+ SSM_TOKEN_PATH = local.runner_jit_ssm_path
+ })
+
+ scale_up_environment_variables = local.microvm_environment_variables
+ scale_down_environment_variables = local.microvm_environment_variables
+ pool_environment_variables = local.microvm_environment_variables
+}
diff --git a/modules/compute-providers/aws/microvm/logging.tf b/modules/compute-providers/aws/microvm/logging.tf
new file mode 100644
index 0000000000..18f87ffeed
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/logging.tf
@@ -0,0 +1,86 @@
+locals {
+ provider_tags = merge(
+ {
+ "Name" = format("%s-action-runner", var.prefix)
+ },
+ var.tags,
+ )
+
+ log_group_tags = merge(
+ local.provider_tags,
+ var.observability.logs.tags,
+ )
+
+ ssm_config_ssm_path = "/${trim(var.ssm.paths.root, "/")}/${trim(var.ssm.paths.config, "/")}"
+ ssm_parameter_tags = merge(
+ local.provider_tags,
+ var.ssm.tags,
+ var.ssm.parameters.tags,
+ )
+
+ runner_log_files = var.config.log_files != null ? var.config.log_files : [
+ {
+ log_group_name = "internal_service"
+ prefix_log_group = true
+ file_path = "/var/log/microvm/internal-services.log"
+ log_stream_name = "{microvm_id}"
+ log_class = "STANDARD"
+ },
+ {
+ log_group_name = "run"
+ prefix_log_group = true
+ file_path = "/var/log/microvm/run.log"
+ log_stream_name = "{microvm_id}"
+ log_class = "STANDARD"
+ },
+ {
+ log_group_name = "runner"
+ prefix_log_group = true
+ file_path = "/opt/actions-runner/_diag/Runner_**.log"
+ log_stream_name = "{microvm_id}"
+ log_class = "STANDARD"
+ },
+ ]
+
+ logfiles = var.config.cloudwatch_agent.enabled ? [for log_file in local.runner_log_files : {
+ log_group_name = log_file.prefix_log_group ? "/github-self-hosted-runners/${var.prefix}/${log_file.log_group_name}" : "/${log_file.log_group_name}"
+ log_stream_name = log_file.log_stream_name
+ file_path = log_file.file_path
+ log_group_class = log_file.log_class
+ }] : []
+ runner_log_group_names = distinct([for log_file in local.logfiles : log_file.log_group_name])
+ runner_log_group_classes = [for name in local.runner_log_group_names : [
+ for log_file in local.logfiles : log_file.log_group_class
+ if log_file.log_group_name == name
+ ][0]]
+}
+
+resource "aws_cloudwatch_log_group" "runtime" {
+ name = "/github-self-hosted-runners/${var.prefix}/microvm"
+ retention_in_days = var.observability.logs.retention_in_days
+ kms_key_id = var.observability.logs.kms_key_id
+ log_group_class = var.observability.logs.class
+ tags = local.log_group_tags
+}
+
+resource "aws_ssm_parameter" "cloudwatch_agent_config_runner" {
+ count = var.config.cloudwatch_agent.enabled ? 1 : 0
+
+ name = "${local.ssm_config_ssm_path}/cloudwatch_agent_config_runner"
+ type = "String"
+ value = var.config.cloudwatch_agent.config != null ? var.config.cloudwatch_agent.config : templatefile(
+ "${path.module}/templates/cloudwatch_config.json",
+ { logfiles = jsonencode(local.logfiles) },
+ )
+ tags = local.ssm_parameter_tags
+}
+
+resource "aws_cloudwatch_log_group" "gh_runners" {
+ count = length(local.runner_log_group_names)
+
+ name = local.runner_log_group_names[count.index]
+ retention_in_days = var.observability.logs.retention_in_days
+ kms_key_id = var.observability.logs.kms_key_id
+ log_group_class = local.runner_log_group_classes[count.index]
+ tags = local.log_group_tags
+}
diff --git a/modules/compute-providers/aws/microvm/outputs.tf b/modules/compute-providers/aws/microvm/outputs.tf
new file mode 100644
index 0000000000..6200a8f54e
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/outputs.tf
@@ -0,0 +1,23 @@
+output "environment_variables" {
+ description = "Provider-specific Lambda environment variable fragments consumed by runner-config."
+ value = local.provider_environment_variables
+}
+
+output "policies" {
+ description = "Provider-specific IAM policy fragments consumed by runner-config."
+ value = local.provider_policies
+}
+
+output "resources" {
+ description = "Provider-specific MicroVM resources exposed by runner-config."
+ value = local.provider_resources
+}
+
+output "provider" {
+ description = "Nested Lambda MicroVM compute-provider contract consumed by runner-config."
+ value = {
+ environment_variables = local.provider_environment_variables
+ policies = local.provider_policies
+ resources = local.provider_resources
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/policies-runner.tf b/modules/compute-providers/aws/microvm/policies-runner.tf
new file mode 100644
index 0000000000..9b514e396a
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/policies-runner.tf
@@ -0,0 +1,81 @@
+data "aws_caller_identity" "current" {}
+
+locals {
+ ssm_parameter_arn_prefix = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter"
+ runner_token_path_arn = "${local.ssm_parameter_arn_prefix}/${trim(var.ssm.paths.root, "/")}/${trim(var.ssm.paths.tokens, "/")}/*"
+ runner_metadata_tags_arn = "${local.microvm_metadata_path_arn}/*.tags"
+ runner_enable_cloudwatch_arn = "${local.ssm_parameter_arn_prefix}${local.ssm_config_ssm_path}/enable_cloudwatch"
+ runner_cloudwatch_config_arn = "${local.ssm_parameter_arn_prefix}${local.ssm_config_ssm_path}/cloudwatch_agent_config_runner"
+ runner_cloudwatch_log_group_arns = [for name in local.runner_log_group_names :
+ "arn:${var.aws_partition}:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:${name}"
+ ]
+ runner_inline_policies = merge({
+ ssm_jit = {
+ name = "runner-microvm-ssm-jit"
+ policy_json = data.aws_iam_policy_document.runner_ssm_jit.json
+ }
+ runtime_logs = {
+ name = "runner-microvm-runtime-logs"
+ policy_json = data.aws_iam_policy_document.runner_runtime_logs.json
+ }
+ runner_metadata = {
+ name = "runner-microvm-metadata"
+ policy_json = data.aws_iam_policy_document.runner_metadata.json
+ }
+ }, var.config.cloudwatch_agent.enabled ? {
+ cloudwatch = {
+ name = "runner-microvm-cloudwatch"
+ policy_json = data.aws_iam_policy_document.runner_cloudwatch[0].json
+ }
+ } : {})
+}
+
+data "aws_iam_policy_document" "runner_ssm_jit" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:DeleteParameter",
+ "ssm:GetParameter",
+ ]
+ resources = [local.runner_token_path_arn]
+ }
+}
+
+data "aws_iam_policy_document" "runner_metadata" {
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameter"]
+ resources = [local.runner_metadata_tags_arn, local.runner_enable_cloudwatch_arn]
+ }
+}
+
+data "aws_iam_policy_document" "runner_cloudwatch" {
+ count = var.config.cloudwatch_agent.enabled ? 1 : 0
+
+ statement {
+ effect = "Allow"
+ actions = ["ssm:GetParameter"]
+ resources = [local.runner_cloudwatch_config_arn]
+ }
+
+ statement {
+ effect = "Allow"
+ actions = [
+ "logs:CreateLogStream",
+ "logs:DescribeLogStreams",
+ "logs:PutLogEvents",
+ ]
+ resources = [for arn in local.runner_cloudwatch_log_group_arns : "${arn}:*"]
+ }
+}
+
+data "aws_iam_policy_document" "runner_runtime_logs" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ ]
+ resources = ["${aws_cloudwatch_log_group.runtime.arn}:*"]
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/provider-contract.tf b/modules/compute-providers/aws/microvm/provider-contract.tf
new file mode 100644
index 0000000000..2f487a01d7
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/provider-contract.tf
@@ -0,0 +1,36 @@
+locals {
+ provider_environment_variables = {
+ scale_up = local.scale_up_environment_variables
+ scale_down = local.scale_down_environment_variables
+ pool = local.pool_environment_variables
+ }
+
+ provider_policies = {
+ runner = {
+ inline_policies = local.runner_inline_policies
+ managed_policy_arns = var.runner.iam.managed_policy_arns
+ }
+ scale_up = {
+ iam_policy_json = data.aws_iam_policy_document.scale_up.json
+ additional_iam_policy_json = var.config.iam.additional_policy_json.scale_up
+ managed_policy_enabled = var.config.iam.managed_policies.scale_up != null
+ managed_policy_arn = try(var.config.iam.managed_policies.scale_up.arn, null)
+ }
+ scale_down = {
+ iam_policy_json = data.aws_iam_policy_document.scale_down.json
+ }
+ pool = {
+ iam_policy_json = data.aws_iam_policy_document.scale_up.json
+ managed_policy_enabled = var.config.iam.managed_policies.pool != null
+ managed_policy_arn = try(var.config.iam.managed_policies.pool.arn, null)
+ }
+ }
+
+ provider_resources = {
+ image_arn = var.config.image_arn
+ image_version = var.config.image_version
+ execution_role_arn = var.runner.iam.role.arn
+ runners_log_groups = concat([aws_cloudwatch_log_group.runtime], aws_cloudwatch_log_group.gh_runners)
+ logfiles = local.logfiles
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/runner-config.tf b/modules/compute-providers/aws/microvm/runner-config.tf
new file mode 100644
index 0000000000..de3b775106
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/runner-config.tf
@@ -0,0 +1,6 @@
+resource "aws_ssm_parameter" "runner_enable_cloudwatch" {
+ name = "${local.ssm_config_ssm_path}/enable_cloudwatch"
+ type = "String"
+ value = var.config.cloudwatch_agent.enabled
+ tags = local.ssm_parameter_tags
+}
diff --git a/modules/compute-providers/aws/microvm/templates/cloudwatch_config.json b/modules/compute-providers/aws/microvm/templates/cloudwatch_config.json
new file mode 100644
index 0000000000..554de026e5
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/templates/cloudwatch_config.json
@@ -0,0 +1,12 @@
+{
+ "agent": {
+ "metrics_collection_interval": 5
+ },
+ "logs": {
+ "logs_collected": {
+ "files": {
+ "collect_list": ${logfiles}
+ }
+ }
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl b/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl
new file mode 100644
index 0000000000..81f417e21c
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/tests/provider.tftest.hcl
@@ -0,0 +1,649 @@
+mock_provider "aws" {
+ mock_data "aws_caller_identity" {
+ defaults = {
+ account_id = "123456789012"
+ }
+ }
+
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{}"
+ }
+ }
+
+ mock_resource "aws_cloudwatch_log_group" {
+ defaults = {
+ arn = "arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/microvm"
+ }
+ }
+}
+
+variables {
+ aws_region = "eu-west-1"
+ prefix = "microvm-test"
+
+ tags = {
+ Module = "runner"
+ Name = "module"
+ }
+
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ image_version = "3"
+ ingress_network_connectors = [
+ "arn:aws:lambda:eu-west-1:123456789012:network-connector:ingress",
+ ]
+ egress_network_connectors = [
+ "arn:aws:lambda:eu-west-1:123456789012:network-connector:egress",
+ ]
+ environment_variables = {
+ MICROVM_CLUSTER = "runner-cluster"
+ MICROVM_IMAGE_ARN = "caller-cannot-override-provider-contract"
+ MICROVM_METADATA_SSM_PATH = "/caller/cannot/override/provider-contract"
+ MICROVM_METADATA_TAGS = "retired-provider-contract"
+ MICROVM_RUNNER_CONFIG_SSM_ARN = "retired-provider-contract"
+ SSM_TOKEN_PATH = "/caller/cannot/override/token-path"
+ }
+ }
+
+ runner = {
+ name_prefix = "microvm-"
+ iam = {
+ role = {
+ arn = "arn:aws:iam::123456789012:role/microvm-test-runner"
+ name = "microvm-test-runner"
+ }
+ managed_policy_arns = {
+ readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess"
+ }
+ }
+ }
+
+ ssm = {
+ paths = {
+ root = "/github-action-runners"
+ tokens = "tokens"
+ config = "config"
+ }
+ tags = {
+ Name = "ssm"
+ Precedence = "ssm"
+ Ssm = "shared"
+ }
+ parameters = {
+ tags = {
+ Name = "parameter"
+ Parameter = "metadata"
+ Precedence = "parameter"
+ "ghr:environment" = "caller-cannot-override"
+ "ghr:runner_name_prefix" = "caller-cannot-override"
+ "ghr:ssm_config_path" = "caller-cannot-override"
+ }
+ }
+ }
+
+ observability = {
+ logs = {
+ retention_in_days = 30
+ kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/runtime-logs"
+ class = "INFREQUENT_ACCESS"
+ tags = {
+ Name = "microvm-runtime-logs"
+ LogOnly = "runtime"
+ }
+ }
+ }
+}
+
+run "exposes_microvm_control_plane_contract" {
+ command = apply
+
+ assert {
+ condition = toset(keys(output.provider)) == toset(["environment_variables", "policies", "resources"])
+ error_message = "The MicroVM provider contract must expose only integration and resource data."
+ }
+
+ assert {
+ condition = (
+ output.provider.environment_variables.scale_up["MICROVM_CLUSTER"] == "runner-cluster"
+ && output.provider.environment_variables.scale_up["MICROVM_IMAGE_ARN"] == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ && output.provider.environment_variables.scale_up["MICROVM_IMAGE_VERSION"] == "3"
+ && output.provider.environment_variables.scale_up["MICROVM_EXECUTION_ROLE_ARN"] == "arn:aws:iam::123456789012:role/microvm-test-runner"
+ && jsondecode(output.provider.environment_variables.scale_up["MICROVM_INGRESS_NETWORK_CONNECTORS"])[0] == "arn:aws:lambda:eu-west-1:123456789012:network-connector:ingress"
+ && jsondecode(output.provider.environment_variables.scale_up["MICROVM_EGRESS_NETWORK_CONNECTORS"])[0] == "arn:aws:lambda:eu-west-1:123456789012:network-connector:egress"
+ && output.provider.environment_variables.scale_up["MICROVM_LOG_GROUP"] == "/github-self-hosted-runners/microvm-test/microvm"
+ && output.provider.environment_variables.scale_up["MICROVM_METADATA_SSM_PATH"] == "/github-action-runners/config/microvm-metadata"
+ && output.provider.environment_variables.scale_up["SSM_TOKEN_PATH"] == "/github-action-runners/tokens"
+ )
+ error_message = "The MicroVM provider must map every configured runtime input to the canonical Lambda environment contract."
+ }
+
+ assert {
+ condition = (
+ toset(keys(output.provider.environment_variables.scale_up)) == toset([
+ "MICROVM_CLUSTER",
+ "MICROVM_EGRESS_NETWORK_CONNECTORS",
+ "MICROVM_EXECUTION_ROLE_ARN",
+ "MICROVM_IMAGE_ARN",
+ "MICROVM_IMAGE_VERSION",
+ "MICROVM_INGRESS_NETWORK_CONNECTORS",
+ "MICROVM_LOG_GROUP",
+ "MICROVM_METADATA_SSM_PATH",
+ "SSM_TOKEN_PATH",
+ ])
+ && output.provider.environment_variables.scale_up == output.provider.environment_variables.scale_down
+ && output.provider.environment_variables.scale_up == output.provider.environment_variables.pool
+ && !contains(keys(output.provider.environment_variables.scale_up), "RUNNER_BOOT_TIME_IN_MINUTES")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_IMAGE_IDENTIFIER")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_MAXIMUM_DURATION_IN_SECONDS")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_RUN_CONFIG")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_TAGS")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_METADATA_TAGS")
+ && !contains(keys(output.provider.environment_variables.scale_up), "MICROVM_RUNNER_CONFIG_SSM_ARN")
+ )
+ error_message = "All three control-plane fragments must match the runtime key inventory and omit stale or webhook-owned keys."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.scale_up.statement[0].actions == toset(["lambda:ListMicrovms", "lambda:PassNetworkConnector"])
+ && data.aws_iam_policy_document.scale_up.statement[0].resources == toset(["*"])
+ && data.aws_iam_policy_document.scale_up.statement[1].actions == toset(["lambda:RunMicrovm", "lambda:TerminateMicrovm"])
+ && data.aws_iam_policy_document.scale_up.statement[1].resources == toset(["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"])
+ )
+ error_message = "Scale-up and pool must receive the MicroVM inventory, connector, launch, and cleanup permissions."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.scale_up.statement[2].actions == toset(["ssm:AddTagsToResource", "ssm:DeleteParameter", "ssm:PutParameter"])
+ && data.aws_iam_policy_document.scale_up.statement[2].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_up.statement[3].actions == toset(["ssm:GetParametersByPath"])
+ && data.aws_iam_policy_document.scale_up.statement[3].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*",
+ ])
+ && data.aws_iam_policy_document.scale_up.statement[4].actions == toset(["ssm:GetParameters"])
+ && data.aws_iam_policy_document.scale_up.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ )
+ error_message = "Scale-up and pool must read the metadata hierarchy and exact JIT fence records while keeping metadata writes child-scoped."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.scale_up.statement[5].actions == toset(["iam:PassRole"])
+ && data.aws_iam_policy_document.scale_up.statement[5].resources == toset(["arn:aws:iam::123456789012:role/microvm-test-runner"])
+ && length(data.aws_iam_policy_document.scale_up.statement[5].condition) == 0
+ && data.aws_iam_policy_document.scale_up.statement[6].actions == toset(["ssm:DeleteParameter"])
+ && data.aws_iam_policy_document.scale_up.statement[6].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ )
+ error_message = "Scale-up and pool must receive exact runner-role PassRole and lane-token cleanup grants."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.scale_down.statement[0].actions == toset(["lambda:ListMicrovms"])
+ && data.aws_iam_policy_document.scale_down.statement[0].resources == toset(["*"])
+ && data.aws_iam_policy_document.scale_down.statement[1].actions == toset(["lambda:TerminateMicrovm"])
+ && data.aws_iam_policy_document.scale_down.statement[1].resources == toset(["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"])
+ && data.aws_iam_policy_document.scale_down.statement[2].actions == toset(["ssm:DeleteParameter", "ssm:PutParameter"])
+ && data.aws_iam_policy_document.scale_down.statement[2].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_down.statement[3].actions == toset(["ssm:GetParametersByPath"])
+ && data.aws_iam_policy_document.scale_down.statement[3].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*",
+ ])
+ && data.aws_iam_policy_document.scale_down.statement[4].actions == toset(["ssm:DeleteParameter"])
+ && data.aws_iam_policy_document.scale_down.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ )
+ error_message = "Scale-down must receive lifecycle, metadata, and exact lane-token cleanup permissions."
+ }
+
+ assert {
+ condition = (
+ length(setintersection(toset(flatten(data.aws_iam_policy_document.scale_up.statement[*].actions)), toset(["lambda:ListTags", "lambda:TagResource", "lambda:UntagResource"]))) == 0
+ && length(setintersection(toset(flatten(data.aws_iam_policy_document.scale_down.statement[*].actions)), toset(["lambda:ListTags", "lambda:TagResource", "lambda:UntagResource"]))) == 0
+ )
+ error_message = "The MicroVM provider must not grant unsupported runtime tagging actions."
+ }
+
+ assert {
+ condition = (
+ toset(keys(output.provider.policies)) == toset(["runner", "scale_up", "scale_down", "pool"])
+ && toset(keys(output.provider.policies.runner.inline_policies)) == toset(["cloudwatch", "runner_metadata", "runtime_logs", "ssm_jit"])
+ && output.provider.policies.runner.inline_policies.cloudwatch.name == "runner-microvm-cloudwatch"
+ && output.provider.policies.runner.inline_policies.runner_metadata.name == "runner-microvm-metadata"
+ && output.provider.policies.runner.inline_policies.ssm_jit.name == "runner-microvm-ssm-jit"
+ && output.provider.policies.runner.inline_policies.runtime_logs.name == "runner-microvm-runtime-logs"
+ && output.provider.policies.runner.managed_policy_arns["readonly"] == "arn:aws:iam::aws:policy/ReadOnlyAccess"
+ && !output.provider.policies.scale_up.managed_policy_enabled
+ && !output.provider.policies.pool.managed_policy_enabled
+ )
+ error_message = "The MicroVM provider must return policy fragments grouped by common component."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.runner_ssm_jit.statement[0].actions == toset(["ssm:DeleteParameter", "ssm:GetParameter"])
+ && data.aws_iam_policy_document.runner_ssm_jit.statement[0].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.runner_metadata.statement[0].actions == toset(["ssm:GetParameter"])
+ && data.aws_iam_policy_document.runner_metadata.statement[0].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/enable_cloudwatch",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*.tags",
+ ])
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[0].actions == toset(["ssm:GetParameter"])
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[0].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/cloudwatch_agent_config_runner"])
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[1].actions == toset(["logs:CreateLogStream", "logs:DescribeLogStreams", "logs:PutLogEvents"])
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[1].resources == toset([
+ "arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/internal_service:*",
+ "arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/run:*",
+ "arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/runner:*",
+ ])
+ && length(data.aws_iam_policy_document.runner_runtime_logs.statement) == 1
+ && data.aws_iam_policy_document.runner_runtime_logs.statement[0].actions == toset(["logs:CreateLogStream", "logs:PutLogEvents"])
+ && data.aws_iam_policy_document.runner_runtime_logs.statement[0].resources == toset(["arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/microvm:*"])
+ )
+ error_message = "Managed MicroVM runners must receive exact lane configuration, metadata, JIT, native-runtime, and CloudWatch-agent permissions."
+ }
+
+ assert {
+ condition = (
+ toset(keys(output.provider.resources)) == toset(["execution_role_arn", "image_arn", "image_version", "logfiles", "runners_log_groups"])
+ && output.provider.resources.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ && output.provider.resources.image_version == "3"
+ && output.provider.resources.execution_role_arn == "arn:aws:iam::123456789012:role/microvm-test-runner"
+ && length(output.provider.resources.runners_log_groups) == 4
+ && output.provider.resources.runners_log_groups[0].name == "/github-self-hosted-runners/microvm-test/microvm"
+ && toset(slice(output.provider.resources.runners_log_groups[*].name, 1, 4)) == toset([
+ "/github-self-hosted-runners/microvm-test/internal_service",
+ "/github-self-hosted-runners/microvm-test/run",
+ "/github-self-hosted-runners/microvm-test/runner",
+ ])
+ && output.provider.resources.logfiles == local.logfiles
+ )
+ error_message = "The MicroVM provider must expose its selected image, execution role, native runtime group, and CloudWatch-agent resources."
+ }
+
+ assert {
+ condition = (
+ aws_cloudwatch_log_group.runtime.name == "/github-self-hosted-runners/microvm-test/microvm"
+ && aws_cloudwatch_log_group.runtime.retention_in_days == 30
+ && aws_cloudwatch_log_group.runtime.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/runtime-logs"
+ && aws_cloudwatch_log_group.runtime.log_group_class == "INFREQUENT_ACCESS"
+ && aws_cloudwatch_log_group.runtime.tags == tomap({
+ Name = "microvm-runtime-logs"
+ Module = "runner"
+ LogOnly = "runtime"
+ })
+ )
+ error_message = "The MicroVM provider must own its lane-scoped log group and apply the common observability lifecycle and tag scopes."
+ }
+
+ assert {
+ condition = (
+ aws_ssm_parameter.runner_enable_cloudwatch.name == "/github-action-runners/config/enable_cloudwatch"
+ && aws_ssm_parameter.runner_enable_cloudwatch.value == "true"
+ && length(aws_ssm_parameter.cloudwatch_agent_config_runner) == 1
+ && aws_ssm_parameter.cloudwatch_agent_config_runner[0].name == "/github-action-runners/config/cloudwatch_agent_config_runner"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Name"] == "parameter"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Module"] == "runner"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Ssm"] == "shared"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Parameter"] == "metadata"
+ && aws_ssm_parameter.runner_enable_cloudwatch.tags["Precedence"] == "parameter"
+ && aws_ssm_parameter.cloudwatch_agent_config_runner[0].tags == aws_ssm_parameter.runner_enable_cloudwatch.tags
+ )
+ error_message = "The MicroVM provider must publish the EC2-compatible CloudWatch enablement and agent-config parameters with standard SSM tag precedence."
+ }
+
+ assert {
+ condition = (
+ length(local.logfiles) == 3
+ && local.logfiles[0].file_path == "/var/log/microvm/internal-services.log"
+ && local.logfiles[0].log_group_name == "/github-self-hosted-runners/microvm-test/internal_service"
+ && local.logfiles[1].file_path == "/var/log/microvm/run.log"
+ && local.logfiles[1].log_group_name == "/github-self-hosted-runners/microvm-test/run"
+ && local.logfiles[2].file_path == "/opt/actions-runner/_diag/Runner_**.log"
+ && local.logfiles[2].log_group_name == "/github-self-hosted-runners/microvm-test/runner"
+ && alltrue([for log_file in local.logfiles : (
+ log_file.log_group_class == "STANDARD"
+ && log_file.log_stream_name == "{microvm_id}"
+ )])
+ && length(jsondecode(aws_ssm_parameter.cloudwatch_agent_config_runner[0].value).logs.logs_collected.files.collect_list) == 3
+ && toset(aws_cloudwatch_log_group.gh_runners[*].name) == toset(local.runner_log_group_names)
+ && alltrue([for log_group in aws_cloudwatch_log_group.gh_runners : (
+ log_group.retention_in_days == 30
+ && log_group.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/runtime-logs"
+ && log_group.tags == aws_cloudwatch_log_group.runtime.tags
+ )])
+ )
+ error_message = "The default MicroVM agent configuration must route internal-service, run, and runner files to separately managed log groups."
+ }
+}
+
+run "normalizes_ssm_paths_and_arns" {
+ command = plan
+
+ variables {
+ ssm = {
+ paths = {
+ root = "/github-action-runners/"
+ tokens = "/tokens/"
+ config = "/config/"
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ output.provider.environment_variables.scale_up["MICROVM_METADATA_SSM_PATH"] == "/github-action-runners/config/microvm-metadata"
+ && output.provider.environment_variables.scale_up["SSM_TOKEN_PATH"] == "/github-action-runners/tokens"
+ && data.aws_iam_policy_document.scale_up.statement[6].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.scale_down.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.runner_ssm_jit.statement[0].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.runner_metadata.statement[0].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/enable_cloudwatch",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*.tags",
+ ])
+ )
+ error_message = "The MicroVM provider must normalize SSM path segments before exposing hook values or IAM resources."
+ }
+}
+
+run "accepts_external_runner_role_and_policy_overrides" {
+ command = plan
+
+ variables {
+ runner = {
+ iam = {
+ role = {
+ arn = "arn:aws:iam::123456789012:role/external-microvm-runner"
+ name = "external-microvm-runner"
+ managed = false
+ }
+ }
+ }
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-override"
+ iam = {
+ resource_arns = {
+ images = ["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-*"]
+ }
+ additional_policy_json = {
+ scale_up = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ }
+ managed_policies = {
+ scale_up = {
+ arn = "arn:aws:iam::123456789012:policy/microvm-scale-up"
+ }
+ pool = {
+ arn = "arn:aws:iam::123456789012:policy/microvm-pool"
+ }
+ }
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ output.provider.environment_variables.scale_up["MICROVM_EXECUTION_ROLE_ARN"] == "arn:aws:iam::123456789012:role/external-microvm-runner"
+ && output.provider.environment_variables.scale_up["MICROVM_INGRESS_NETWORK_CONNECTORS"] == ""
+ && output.provider.environment_variables.scale_up["MICROVM_EGRESS_NETWORK_CONNECTORS"] == ""
+ && output.provider.environment_variables.scale_up["MICROVM_LOG_GROUP"] == "/github-self-hosted-runners/microvm-test/microvm"
+ && data.aws_iam_policy_document.scale_up.statement[0].resources == toset(["*"])
+ && data.aws_iam_policy_document.scale_up.statement[1].resources == toset(["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-*"])
+ && data.aws_iam_policy_document.scale_up.statement[2].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_up.statement[3].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*",
+ ])
+ && data.aws_iam_policy_document.scale_up.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_up.statement[5].resources == toset(["arn:aws:iam::123456789012:role/external-microvm-runner"])
+ && data.aws_iam_policy_document.scale_up.statement[6].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ && data.aws_iam_policy_document.scale_down.statement[0].resources == toset(["*"])
+ && data.aws_iam_policy_document.scale_down.statement[1].resources == toset(["arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-*"])
+ && data.aws_iam_policy_document.scale_down.statement[2].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*"])
+ && data.aws_iam_policy_document.scale_down.statement[3].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*",
+ ])
+ && data.aws_iam_policy_document.scale_down.statement[4].resources == toset(["arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/tokens/*"])
+ )
+ error_message = "The provider-neutral external runner role and image allowlist must reach their scoped statements without narrowing required list, connector, or metadata permissions."
+ }
+
+ assert {
+ condition = (
+ output.provider.policies.scale_up.additional_iam_policy_json == "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ && output.provider.policies.scale_up.managed_policy_enabled
+ && output.provider.policies.scale_up.managed_policy_arn == "arn:aws:iam::123456789012:policy/microvm-scale-up"
+ && output.provider.policies.pool.managed_policy_enabled
+ && output.provider.policies.pool.managed_policy_arn == "arn:aws:iam::123456789012:policy/microvm-pool"
+ )
+ error_message = "Optional MicroVM policy attachments must stay controlled by wrapper presence."
+ }
+
+
+ assert {
+ condition = (
+ toset(keys(output.provider.policies.runner.inline_policies)) == toset(["cloudwatch", "runner_metadata", "runtime_logs", "ssm_jit"])
+ && data.aws_iam_policy_document.runner_metadata.statement[0].resources == toset([
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/enable_cloudwatch",
+ "arn:aws:ssm:eu-west-1:123456789012:parameter/github-action-runners/config/microvm-metadata/*.tags",
+ ])
+ && length(data.aws_iam_policy_document.runner_runtime_logs.statement) == 1
+ && data.aws_iam_policy_document.runner_runtime_logs.statement[0].resources == toset(["arn:aws:logs:eu-west-1:123456789012:log-group:/github-self-hosted-runners/microvm-test/microvm:*"])
+ )
+ error_message = "The provider contract must keep plan-known runner-policy keys and scope runtime logging to its provider-managed group."
+ }
+}
+
+run "disables_cloudwatch_agent_without_disabling_native_runtime_logging" {
+ command = apply
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ cloudwatch_agent = {
+ enabled = false
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ tostring(aws_ssm_parameter.runner_enable_cloudwatch.value) == "false"
+ && length(aws_ssm_parameter.cloudwatch_agent_config_runner) == 0
+ && length(aws_cloudwatch_log_group.gh_runners) == 0
+ && length(local.logfiles) == 0
+ && !contains(keys(output.provider.policies.runner.inline_policies), "cloudwatch")
+ && length(output.provider.resources.runners_log_groups) == 1
+ && output.provider.resources.runners_log_groups[0].name == aws_cloudwatch_log_group.runtime.name
+ && output.provider.environment_variables.scale_up["MICROVM_LOG_GROUP"] == aws_cloudwatch_log_group.runtime.name
+ )
+ error_message = "Disabling the image CloudWatch agent must retain the explicit false flag and native RunMicrovm logging while removing only agent-owned resources and permissions."
+ }
+}
+
+run "accepts_custom_cloudwatch_agent_config_and_log_group" {
+ command = apply
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ cloudwatch_agent = {
+ enabled = true
+ config = "{\"agent\":{\"region\":\"eu-west-1\"}}"
+ }
+ log_files = [{
+ log_group_name = "custom-microvm"
+ prefix_log_group = false
+ file_path = "/var/log/custom.log"
+ log_stream_name = "{microvm_id}/custom"
+ log_class = "INFREQUENT_ACCESS"
+ }]
+ }
+ }
+
+ assert {
+ condition = (
+ aws_ssm_parameter.cloudwatch_agent_config_runner[0].value == "{\"agent\":{\"region\":\"eu-west-1\"}}"
+ && length(local.logfiles) == 1
+ && local.logfiles[0].file_path == "/var/log/custom.log"
+ && local.logfiles[0].log_group_class == "INFREQUENT_ACCESS"
+ && local.logfiles[0].log_group_name == "/custom-microvm"
+ && local.logfiles[0].log_stream_name == "{microvm_id}/custom"
+ && aws_cloudwatch_log_group.gh_runners[0].name == "/custom-microvm"
+ && aws_cloudwatch_log_group.gh_runners[0].log_group_class == "INFREQUENT_ACCESS"
+ && data.aws_iam_policy_document.runner_cloudwatch[0].statement[1].resources == toset([
+ "arn:aws:logs:eu-west-1:123456789012:log-group:/custom-microvm:*",
+ ])
+ )
+ error_message = "Custom MicroVM agent configuration and log-file routing must replace the generated defaults without widening IAM."
+ }
+}
+
+run "rejects_invalid_image_arn" {
+ command = plan
+
+ variables {
+ config = {
+ image_arn = "not-a-microvm-image-arn"
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_metadata_path_overlapping_jit_path" {
+ command = plan
+
+ variables {
+ ssm = {
+ paths = {
+ root = "/github-action-runners"
+ tokens = "config/microvm-metadata"
+ config = "config"
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_invalid_metadata_path" {
+ command = plan
+
+ variables {
+ ssm = {
+ paths = {
+ root = "/github-action-runners"
+ tokens = "tokens"
+ config = "invalid config"
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_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
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ iam = {
+ resource_arns = {
+ images = []
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_invalid_network_connector" {
+ command = plan
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ ingress_network_connectors = [
+ " ",
+ ]
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_more_than_ten_network_connectors" {
+ command = plan
+
+ variables {
+ config = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner"
+ egress_network_connectors = [
+ for index in range(11) :
+ "arn:aws:lambda:eu-west-1:123456789012:network-connector:egress-${index}"
+ ]
+ }
+ }
+
+ expect_failures = [terraform_data.validate_config]
+}
+
+run "rejects_unsupported_runner_architecture" {
+ command = plan
+
+ variables {
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ iam = {
+ role = {
+ arn = "arn:aws:iam::123456789012:role/microvm-test-runner"
+ name = "microvm-test-runner"
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_runner]
+}
+
+run "rejects_unsupported_runner_os" {
+ command = plan
+
+ variables {
+ runner = {
+ os = "windows"
+ architecture = "arm64"
+ iam = {
+ role = {
+ arn = "arn:aws:iam::123456789012:role/microvm-test-runner"
+ name = "microvm-test-runner"
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_runner]
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/README.md b/modules/compute-providers/aws/microvm/trust-policy/README.md
new file mode 100644
index 0000000000..43302d1055
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/README.md
@@ -0,0 +1,41 @@
+# AWS Lambda MicroVM runner trust policy
+
+This internal submodule builds the MicroVM runner-role trust policy independently from the runtime module that consumes the role. It preserves the default Lambda service trust and optionally merges an additional IAM trust policy document supplied by the common runner configuration.
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.4.0 |
+| [aws](#requirement\_aws) | >= 6.33 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.33 |
+
+## Modules
+
+No modules.
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_iam_policy_document.assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [additional\_trust\_policy\_json](#input\_additional\_trust\_policy\_json) | Optional IAM policy document merged with the MicroVM provider's default runner-role trust policy. | `string` | `null` | no |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [assume\_role\_policy](#output\_assume\_role\_policy) | MicroVM runner-role trust policy including any additional trust statements. |
+
diff --git a/modules/compute-providers/aws/microvm/trust-policy/assume-role.tf b/modules/compute-providers/aws/microvm/trust-policy/assume-role.tf
new file mode 100644
index 0000000000..3654bce8bf
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/assume-role.tf
@@ -0,0 +1,21 @@
+data "aws_iam_policy_document" "default" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "sts:AssumeRole",
+ "sts:TagSession",
+ ]
+
+ principals {
+ type = "Service"
+ identifiers = ["lambda.amazonaws.com"]
+ }
+ }
+}
+
+data "aws_iam_policy_document" "assume_role" {
+ source_policy_documents = compact([
+ data.aws_iam_policy_document.default.json,
+ var.additional_trust_policy_json,
+ ])
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/outputs.tf b/modules/compute-providers/aws/microvm/trust-policy/outputs.tf
new file mode 100644
index 0000000000..8564675873
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/outputs.tf
@@ -0,0 +1,4 @@
+output "assume_role_policy" {
+ description = "MicroVM runner-role trust policy including any additional trust statements."
+ value = data.aws_iam_policy_document.assume_role.json
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/tests/trust-policy.tftest.hcl b/modules/compute-providers/aws/microvm/trust-policy/tests/trust-policy.tftest.hcl
new file mode 100644
index 0000000000..5f0173ebc1
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/tests/trust-policy.tftest.hcl
@@ -0,0 +1,59 @@
+mock_provider "aws" {
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{}"
+ }
+ }
+}
+
+run "returns_default_microvm_trust_policy" {
+ command = plan
+
+ assert {
+ condition = toset(data.aws_iam_policy_document.default.statement[0].actions) == toset(["sts:AssumeRole", "sts:TagSession"])
+ error_message = "The MicroVM runner role must allow assume-role and tagged sessions."
+ }
+
+ assert {
+ condition = anytrue([
+ for principal in data.aws_iam_policy_document.default.statement[0].principals :
+ principal.type == "Service" && toset(principal.identifiers) == toset(["lambda.amazonaws.com"])
+ ])
+ error_message = "The MicroVM runner role must trust the Lambda service principal required by the provider."
+ }
+
+ assert {
+ condition = (
+ length(data.aws_iam_policy_document.assume_role.source_policy_documents) == 1
+ && output.assume_role_policy == data.aws_iam_policy_document.assume_role.json
+ )
+ error_message = "The MicroVM trust-policy module must return the default trust document as assume_role_policy."
+ }
+}
+
+run "merges_additional_trust_policy" {
+ command = plan
+
+ variables {
+ additional_trust_policy_json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"TrustDeploymentRole\",\"Effect\":\"Allow\",\"Action\":\"sts:AssumeRole\",\"Principal\":{\"AWS\":\"arn:aws:iam::123456789012:role/deployer\"}}]}"
+ }
+
+ assert {
+ condition = (
+ length(data.aws_iam_policy_document.assume_role.source_policy_documents) == 2
+ && contains(data.aws_iam_policy_document.assume_role.source_policy_documents, var.additional_trust_policy_json)
+ && output.assume_role_policy == data.aws_iam_policy_document.assume_role.json
+ )
+ error_message = "The MicroVM trust-policy module must merge and return the additional trust policy document."
+ }
+}
+
+run "rejects_invalid_additional_trust_policy" {
+ command = plan
+
+ variables {
+ additional_trust_policy_json = "{"
+ }
+
+ expect_failures = [var.additional_trust_policy_json]
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/variables.tf b/modules/compute-providers/aws/microvm/trust-policy/variables.tf
new file mode 100644
index 0000000000..1af67309d2
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/variables.tf
@@ -0,0 +1,10 @@
+variable "additional_trust_policy_json" {
+ description = "Optional IAM policy document merged with the MicroVM provider's default runner-role trust policy."
+ type = string
+ default = null
+
+ validation {
+ condition = var.additional_trust_policy_json == null ? true : can(jsondecode(var.additional_trust_policy_json))
+ error_message = "additional_trust_policy_json must be valid JSON when set."
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/trust-policy/versions.tf b/modules/compute-providers/aws/microvm/trust-policy/versions.tf
new file mode 100644
index 0000000000..3ef011ea0a
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/trust-policy/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.4.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.33"
+ }
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/validations.tf b/modules/compute-providers/aws/microvm/validations.tf
new file mode 100644
index 0000000000..17c78b1d81
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/validations.tf
@@ -0,0 +1,80 @@
+resource "terraform_data" "validate_config" {
+ lifecycle {
+ precondition {
+ condition = can(regex("^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$", var.config.image_arn))
+ error_message = "compute_provider.aws.microvm.image_arn must be a Lambda MicroVM image ARN."
+ }
+
+ precondition {
+ condition = var.config.iam.resource_arns.images == null ? true : (
+ length(var.config.iam.resource_arns.images) > 0 &&
+ alltrue([
+ for image_arn in var.config.iam.resource_arns.images :
+ image_arn == "*" || can(regex("^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$", image_arn))
+ ])
+ )
+ error_message = "compute_provider.aws.microvm.iam.resource_arns.images must be null or a non-empty list containing only * or Lambda MicroVM image ARN patterns."
+ }
+
+ precondition {
+ condition = (
+ length(var.config.ingress_network_connectors) <= 10 &&
+ alltrue([
+ for connector in var.config.ingress_network_connectors :
+ can(regex("^arn:[^:]+:lambda:[^:]+:([0-9]{12}|aws):network-connector:[^[:space:]]+$", connector))
+ ])
+ )
+ error_message = "compute_provider.aws.microvm.ingress_network_connectors must contain at most 10 Lambda network-connector ARNs."
+ }
+
+ precondition {
+ condition = (
+ length(var.config.egress_network_connectors) <= 10 &&
+ alltrue([
+ for connector in var.config.egress_network_connectors :
+ can(regex("^arn:[^:]+:lambda:[^:]+:([0-9]{12}|aws):network-connector:[^[:space:]]+$", connector))
+ ])
+ )
+ error_message = "compute_provider.aws.microvm.egress_network_connectors must contain at most 10 Lambda network-connector ARNs."
+ }
+
+ precondition {
+ condition = try(var.config.iam.additional_policy_json.scale_up, null) == null ? true : can(jsondecode(var.config.iam.additional_policy_json.scale_up))
+ error_message = "compute_provider.aws.microvm.iam.additional_policy_json.scale_up must be valid JSON when set."
+ }
+
+ precondition {
+ condition = !(
+ local.microvm_metadata_ssm_path == local.runner_jit_ssm_path ||
+ startswith(local.microvm_metadata_ssm_path, "${local.runner_jit_ssm_path}/") ||
+ startswith(local.runner_jit_ssm_path, "${local.microvm_metadata_ssm_path}/")
+ )
+ error_message = "The MicroVM metadata Parameter Store path must be separate from the runner JIT configuration path."
+ }
+
+ precondition {
+ condition = (
+ startswith(var.ssm.paths.root, "/") &&
+ trim(var.ssm.paths.root, "/") != "" &&
+ trim(var.ssm.paths.config, "/") != "" &&
+ can(regex("^/[A-Za-z0-9_./-]+$", local.microvm_metadata_ssm_path)) &&
+ 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."
+ }
+ }
+}
+
+resource "terraform_data" "validate_runner" {
+ lifecycle {
+ precondition {
+ condition = var.runner.os == "linux" && var.runner.architecture == "arm64"
+ error_message = "Lambda MicroVM runners require runner.os = linux and runner.architecture = arm64."
+ }
+
+ precondition {
+ condition = length(var.runner.name_prefix) <= 45
+ error_message = "runner.name_prefix must be at most 45 characters."
+ }
+ }
+}
diff --git a/modules/compute-providers/aws/microvm/variables.tf b/modules/compute-providers/aws/microvm/variables.tf
new file mode 100644
index 0000000000..388b06135a
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/variables.tf
@@ -0,0 +1,195 @@
+# tflint-ignore: terraform_unused_declarations
+variable "aws_partition" {
+ description = "AWS partition used to construct IAM ARNs."
+ type = string
+ default = "aws"
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "aws_region" {
+ description = "AWS region used by compute-provider resources and policy documents."
+ type = string
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "prefix" {
+ description = "Prefix used to identify resources created for the runner configuration."
+ type = string
+ default = "github-actions"
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "tags" {
+ description = "Base tags available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes."
+ type = map(string)
+ default = {}
+}
+
+variable "config" {
+ description = <<-EOT
+ Lambda MicroVM compute-provider configuration. Paths match `compute_provider.aws.microvm` in runner-config.
+
+ - `image_arn`: ARN of the MicroVM image used to run GitHub runners.
+ - `image_version`: Optional MicroVM image version.
+ - `ingress_network_connectors`: Up to 10 Lambda network-connector ARNs passed to RunMicrovm.
+ - `egress_network_connectors`: Up to 10 Lambda network-connector ARNs passed to RunMicrovm.
+ - `cloudwatch_agent.enabled`: Enables the image CloudWatch agent through the shared runner configuration path.
+ - `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`. Custom log destinations must also be declared in `log_files` so Terraform creates their groups and IAM permissions.
+ - `log_files`: Optional files collected by the CloudWatch agent. Null uses the MicroVM defaults.
+ - `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
+ - `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
+ - `log_files[].file_path`: File or glob read by the CloudWatch agent.
+ - `log_files[].log_stream_name`: Log-stream template. The image replaces `{microvm_id}` with the current MicroVM identifier.
+ - `log_files[].log_class`: CloudWatch log-group class for the collected file.
+ - `environment_variables`: Additional provider-specific Lambda environment variables merged into scale-up, scale-down, and pool.
+ - `iam.resource_arns.images`: Optional MicroVM image ARN allowlist for RunMicrovm and TerminateMicrovm. Null restricts both actions to `image_arn`; set an explicit list when dynamic image overrides are enabled. Provider-required list and connector permissions remain separately scoped to `*`.
+ - `iam.additional_policy_json.scale_up`: Optional additional provider policy attached separately to the scale-up Lambda role.
+ - `iam.managed_policies.scale_up`: Optional managed-policy wrapper attached to the scale-up Lambda role. Wrapper presence controls resource creation during planning.
+ - `iam.managed_policies.scale_up.arn`: ARN of the scale-up managed policy. The ARN may remain unknown until apply.
+ - `iam.managed_policies.pool`: Optional managed-policy wrapper attached to the pool Lambda role. Wrapper presence controls resource creation during planning.
+ - `iam.managed_policies.pool.arn`: ARN of the pool managed policy. The ARN may remain unknown until apply.
+ EOT
+
+ type = object({
+ image_arn = string
+ image_version = optional(string, null)
+ ingress_network_connectors = optional(list(string), [])
+ egress_network_connectors = optional(list(string), [])
+ cloudwatch_agent = optional(object({
+ enabled = optional(bool, true)
+ config = optional(string, null)
+ }), {})
+ log_files = optional(list(object({
+ log_group_name = string
+ prefix_log_group = bool
+ file_path = string
+ log_stream_name = string
+ log_class = optional(string, "STANDARD")
+ })), null)
+ environment_variables = optional(map(string), {})
+ iam = optional(object({
+ resource_arns = optional(object({
+ images = optional(list(string), null)
+ }), {})
+ additional_policy_json = optional(object({
+ scale_up = optional(string, null)
+ }), {})
+ managed_policies = optional(object({
+ scale_up = optional(object({
+ arn = string
+ }), null)
+ pool = optional(object({
+ arn = string
+ }), null)
+ }), {})
+ }), {})
+ })
+
+ nullable = false
+}
+
+variable "runner" {
+ description = <<-EOT
+ Resolved runner settings consumed by the Lambda MicroVM compute provider.
+
+ - `os`: Runner operating system. Lambda MicroVM requires `linux`.
+ - `architecture`: Runner distribution architecture. Lambda MicroVM requires `arm64`.
+ - `name_prefix`: Prefix added to registered runner names.
+ - `run_as_root`: Runs the runner service as root.
+ - `run_as`: Operating-system user used when `run_as_root` is false.
+ - `hooks.job_started`: Script installed as the runner job-started hook.
+ - `hooks.job_completed`: Script installed as the runner job-completed hook.
+ - `iam.role.arn`: Resolved runner-role ARN used as the MicroVM execution role and referenced by provider policies.
+ - `iam.role.name`: Resolved runner-role name used by provider resources.
+ - `iam.role.managed`: Whether runner-config manages the resolved runner role. Callers own an external role and must grant it `ssm:GetParameter` on the lane's `microvm-metadata/*.tags` and `enable_cloudwatch` parameters, `ssm:GetParameter` and `ssm:DeleteParameter` on the lane token path, plus `logs:CreateLogStream` and `logs:PutLogEvents` on the provider-managed runtime log group. When the CloudWatch agent is enabled, it also needs `ssm:GetParameter` on `cloudwatch_agent_config_runner` and stream access to the configured runner log groups.
+ - `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config.
+ - `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`.
+ EOT
+ type = object({
+ os = optional(string, "linux")
+ architecture = optional(string, "arm64")
+ name_prefix = optional(string, "")
+ run_as_root = optional(bool, false)
+ run_as = optional(string, "ec2-user")
+ hooks = optional(object({
+ job_started = optional(string, "")
+ job_completed = optional(string, "")
+ }), {})
+ iam = object({
+ role = object({
+ arn = string
+ name = string
+ managed = optional(bool, true)
+ })
+ managed_policy_arns = optional(map(string), {})
+ path = optional(string, null)
+ })
+ })
+
+ nullable = false
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "github" {
+ description = <<-EOT
+ GitHub Enterprise Server settings available to compute-provider bootstrap data.
+
+ - `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
+ - `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server.
+ EOT
+ type = object({
+ enterprise_server = optional(object({
+ url = optional(string, null)
+ ssl_verify = optional(bool, true)
+ }), {})
+ })
+ default = {}
+ nullable = false
+}
+
+# tflint-ignore: terraform_unused_declarations
+variable "ssm" {
+ description = <<-EOT
+ Parameter Store paths and tag scopes available to compute-provider bootstrap resources.
+
+ - `paths.root`: Root Parameter Store path for the runner configuration.
+ - `paths.tokens`: Path segment used for registration tokens and just-in-time configuration.
+ - `paths.config`: Path segment used for persistent runner and provider configuration. MicroVM control-plane metadata is stored under its `microvm-metadata` child prefix.
+ - `tags`: Shared SSM tags that override module-level `tags`.
+ - `parameters.tags`: Parameter-specific tags that override module-level and shared SSM tags.
+ EOT
+ type = object({
+ paths = object({
+ root = string
+ tokens = string
+ config = string
+ })
+ tags = optional(map(string), {})
+ parameters = optional(object({
+ tags = optional(map(string), {})
+ }), {})
+ })
+
+ nullable = false
+}
+
+variable "observability" {
+ description = <<-EOT
+ Provider-neutral observability settings applied to the provider-managed MicroVM runtime log group.
+
+ - `logs.retention_in_days`: CloudWatch Logs retention period.
+ - `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt the log group.
+ - `logs.class`: CloudWatch log-group class.
+ - `logs.tags`: Tags merged after module-level tags on the log group.
+ EOT
+ type = object({
+ logs = optional(object({
+ retention_in_days = optional(number, 180)
+ kms_key_id = optional(string, null)
+ class = optional(string, "STANDARD")
+ tags = optional(map(string), {})
+ }), {})
+ })
+ default = {}
+ nullable = false
+}
diff --git a/modules/compute-providers/aws/microvm/versions.tf b/modules/compute-providers/aws/microvm/versions.tf
new file mode 100644
index 0000000000..3ef011ea0a
--- /dev/null
+++ b/modules/compute-providers/aws/microvm/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.4.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.33"
+ }
+ }
+}
diff --git a/modules/microvm-foundation/README.md b/modules/microvm-foundation/README.md
new file mode 100644
index 0000000000..77b620c107
--- /dev/null
+++ b/modules/microvm-foundation/README.md
@@ -0,0 +1,134 @@
+# Lambda MicroVM Regional Foundation
+
+This module creates the regional AWS prerequisites for building and running
+Lambda MicroVM GitHub Actions runners and is intended to be deployed once per
+AWS Region.
+
+It manages:
+
+- A private, encrypted, versioned S3 bucket for content-addressed image build artifacts.
+- A Lambda-trusted build role with scoped S3, CloudWatch Logs, and optional ECR pull access.
+- Dedicated no-ingress security groups and native Lambda Network Connector resources for each configured VPC/subnet set.
+- A Lambda-trusted Network Connector operator role and propagation barrier.
+- An unattached runtime usage policy for the reserved image namespace and connector inventory.
+
+The module does not create MicroVM images, runner execution roles, or the
+runner control plane. Attach `usage_policy_arn` to the control-plane role that
+owns the runtime launch operations. The caller must also grant the Terraform
+identity `iam:PassRole` for the operator role with
+`iam:PassedToService=lambda.amazonaws.com`.
+
+The module deliberately does not configure an AWS provider. Configure the
+provider in the root module or example so credentials and account selection
+remain caller-owned.
+
+```hcl
+provider "aws" {
+ region = "eu-west-1"
+}
+
+module "microvm_foundation" {
+ source = "../../modules/microvm-foundation"
+
+ aws_region = "eu-west-1"
+ 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-"
+ usage_policy_name_prefix = "github-actions-runner-microvm-runtime-usage-policy-"
+
+ image_name_prefix = "github-actions-runner-ubuntu-arm64"
+
+ network_connectors = {
+ cicd = {
+ name = "github-actions-runner-egress"
+ vpc_id = "vpc-0123456789abcdef0"
+ subnet_ids = ["subnet-0123456789abcdef0"]
+ }
+ }
+}
+```
+
+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.
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.4.0 |
+| [aws](#requirement\_aws) | >= 6.61 |
+| [time](#requirement\_time) | >= 0.13 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.61 |
+| [time](#provider\_time) | >= 0.13 |
+
+## Modules
+
+No modules.
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_iam_policy.build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource |
+| [aws_iam_policy.usage](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource |
+| [aws_iam_role.build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
+| [aws_iam_role.operator](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
+| [aws_iam_role_policy_attachment.build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource |
+| [aws_iam_role_policy_attachment.operator](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource |
+| [aws_lambdacore_network_connector.connector](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambdacore_network_connector) | resource |
+| [aws_s3_bucket.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource |
+| [aws_s3_bucket_lifecycle_configuration.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_lifecycle_configuration) | resource |
+| [aws_s3_bucket_ownership_controls.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_ownership_controls) | resource |
+| [aws_s3_bucket_policy.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_policy) | resource |
+| [aws_s3_bucket_public_access_block.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_public_access_block) | resource |
+| [aws_s3_bucket_server_side_encryption_configuration.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_server_side_encryption_configuration) | resource |
+| [aws_s3_bucket_versioning.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_versioning) | resource |
+| [aws_security_group.connector](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource |
+| [aws_vpc_security_group_egress_rule.ipv4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource |
+| [aws_vpc_security_group_egress_rule.ipv6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource |
+| [time_sleep.operator_role_propagation](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) | resource |
+| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
+| [aws_iam_policy_document.artifact_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.lambda_service_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.network_connector_assume_operator_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.usage](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source |
+| [aws_subnet.selected](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet) | data source |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [artifact\_bucket\_name](#input\_artifact\_bucket\_name) | Optional name for the regional MicroVM build-artifact bucket. When null, AWS generates the bucket name. | `string` | `null` | no |
+| [artifact\_retention\_days](#input\_artifact\_retention\_days) | Number of days to retain current and noncurrent MicroVM build artifacts. | `number` | `30` | no |
+| [aws\_region](#input\_aws\_region) | AWS region in which to create the Lambda MicroVM prerequisites. | `string` | n/a | yes |
+| [build\_policy\_name\_prefix](#input\_build\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM build policy. | `string` | n/a | yes |
+| [build\_role\_name\_prefix](#input\_build\_role\_name\_prefix) | Name prefix for the Lambda MicroVM build role. | `string` | n/a | yes |
+| [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 |
+| [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 |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [artifact\_bucket\_arn](#output\_artifact\_bucket\_arn) | ARN of the regional S3 bucket used for Lambda MicroVM build artifacts. |
+| [artifact\_bucket\_name](#output\_artifact\_bucket\_name) | Name of the regional S3 bucket used for Lambda MicroVM build artifacts. |
+| [artifact\_prefix](#output\_artifact\_prefix) | Bucket prefix to which the MicroVM image publisher uploads content-addressed build artifacts. |
+| [build\_role\_arn](#output\_build\_role\_arn) | ARN of the Lambda-trusted role used during MicroVM image builds. |
+| [connector\_arns](#output\_connector\_arns) | Map of connector key to the ARN of each Lambda Network Connector. |
+| [security\_group\_ids](#output\_security\_group\_ids) | Map of connector key to its dedicated no-ingress security group ID. |
+| [usage\_policy\_arn](#output\_usage\_policy\_arn) | ARN of the reusable regional policy for operating MicroVM images in the reserved namespace and passing their Network Connectors. |
+
diff --git a/modules/microvm-foundation/build.tf b/modules/microvm-foundation/build.tf
new file mode 100644
index 0000000000..160f3d9399
--- /dev/null
+++ b/modules/microvm-foundation/build.tf
@@ -0,0 +1,84 @@
+# Lambda assumes this role while building an image snapshot.
+data "aws_iam_policy_document" "build" {
+ statement {
+ sid = "ReadRegionalBuildArtifact"
+ effect = "Allow"
+ actions = ["s3:GetObject"]
+ resources = ["${aws_s3_bucket.artifacts.arn}/${local.artifact_prefix}/*"]
+ }
+
+ statement {
+ sid = "CreateMicrovmBuildLogGroups"
+ effect = "Allow"
+ actions = ["logs:CreateLogGroup"]
+ resources = [local.log_group_arn_pattern]
+ }
+
+ statement {
+ sid = "WriteMicrovmBuildLogs"
+ effect = "Allow"
+ actions = [
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ ]
+ resources = [local.log_stream_arn_pattern]
+ }
+
+ dynamic "statement" {
+ for_each = length(var.ecr_repository_arns) > 0 ? [true] : []
+ content {
+ sid = "AuthorizePrivateEcrPull"
+ effect = "Allow"
+ actions = ["ecr:GetAuthorizationToken"]
+ resources = ["*"]
+ }
+ }
+
+ dynamic "statement" {
+ for_each = length(var.ecr_repository_arns) > 0 ? [true] : []
+ content {
+ sid = "PullPrivateEcrImage"
+ effect = "Allow"
+ actions = [
+ "ecr:BatchCheckLayerAvailability",
+ "ecr:BatchGetImage",
+ "ecr:GetDownloadUrlForLayer",
+ ]
+ resources = var.ecr_repository_arns
+ }
+ }
+}
+
+resource "aws_iam_policy" "build" {
+ name_prefix = var.build_policy_name_prefix
+ description = "Regional permissions used by Lambda while building MicroVM images."
+ policy = data.aws_iam_policy_document.build.json
+ tags = var.tags
+}
+
+resource "aws_iam_role_policy_attachment" "build" {
+ role = aws_iam_role.build.name
+ policy_arn = aws_iam_policy.build.arn
+}
+
+data "aws_iam_policy_document" "lambda_service_assume_role" {
+ statement {
+ sid = "LambdaMicrovmService"
+ effect = "Allow"
+ actions = [
+ "sts:AssumeRole",
+ "sts:TagSession",
+ ]
+
+ principals {
+ type = "Service"
+ identifiers = ["lambda.amazonaws.com"]
+ }
+ }
+}
+
+resource "aws_iam_role" "build" {
+ name_prefix = var.build_role_name_prefix
+ assume_role_policy = data.aws_iam_policy_document.lambda_service_assume_role.json
+ tags = var.tags
+}
diff --git a/modules/microvm-foundation/data.tf b/modules/microvm-foundation/data.tf
new file mode 100644
index 0000000000..dc90004cc6
--- /dev/null
+++ b/modules/microvm-foundation/data.tf
@@ -0,0 +1,31 @@
+data "aws_caller_identity" "current" {}
+
+data "aws_partition" "current" {}
+
+data "aws_subnet" "selected" {
+ for_each = local.network_connector_subnets
+ id = each.value.subnet_id
+}
+
+locals {
+ artifact_prefix = "lambda-microvms"
+
+ image_arn_pattern = "arn:${data.aws_partition.current.partition}:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:microvm-image:${var.image_name_prefix}-*"
+ log_group_arn_pattern = "arn:${data.aws_partition.current.partition}:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/microvms/${var.image_name_prefix}-*"
+ log_stream_arn_pattern = "${local.log_group_arn_pattern}:log-stream:*"
+
+ network_connector_subnets = merge({}, [
+ for connector_key, connector in var.network_connectors : {
+ for subnet_id in connector.subnet_ids :
+ "${connector_key}/${subnet_id}" => {
+ connector_key = connector_key
+ subnet_id = subnet_id
+ }
+ }
+ ]...)
+
+ connector_arns = {
+ for connector_key, connector in aws_lambdacore_network_connector.connector :
+ connector_key => connector.arn
+ }
+}
diff --git a/modules/microvm-foundation/network_connector.tf b/modules/microvm-foundation/network_connector.tf
new file mode 100644
index 0000000000..3e41d161b3
--- /dev/null
+++ b/modules/microvm-foundation/network_connector.tf
@@ -0,0 +1,31 @@
+resource "aws_lambdacore_network_connector" "connector" {
+ for_each = var.network_connectors
+
+ name = each.value.name
+ operator_role = aws_iam_role.operator.arn
+
+ configuration {
+ vpc_egress_configuration {
+ associated_compute_resource_types = ["MicroVm"]
+ network_protocol = each.value.network_protocol
+ security_group_ids = [aws_security_group.connector[each.key].id]
+ subnet_ids = sort(tolist(each.value.subnet_ids))
+ }
+ }
+
+ lifecycle {
+ precondition {
+ condition = alltrue([
+ for subnet_id in each.value.subnet_ids :
+ data.aws_subnet.selected["${each.key}/${subnet_id}"].vpc_id == each.value.vpc_id
+ ])
+ error_message = "Every subnet in network_connectors[${each.key}] must belong to its configured vpc_id."
+ }
+ }
+
+ depends_on = [
+ time_sleep.operator_role_propagation,
+ aws_vpc_security_group_egress_rule.ipv4,
+ aws_vpc_security_group_egress_rule.ipv6,
+ ]
+}
diff --git a/modules/microvm-foundation/network_connector_operator.tf b/modules/microvm-foundation/network_connector_operator.tf
new file mode 100644
index 0000000000..fc7d03fd74
--- /dev/null
+++ b/modules/microvm-foundation/network_connector_operator.tf
@@ -0,0 +1,41 @@
+data "aws_iam_policy_document" "network_connector_assume_operator_role" {
+ statement {
+ sid = "LambdaNetworkConnectorService"
+ effect = "Allow"
+ actions = ["sts:AssumeRole"]
+
+ principals {
+ type = "Service"
+ identifiers = ["network-connectors.lambda.amazonaws.com"]
+ }
+ }
+}
+
+resource "aws_iam_role" "operator" {
+ name_prefix = var.network_connector_operator_role_name_prefix
+ assume_role_policy = data.aws_iam_policy_document.network_connector_assume_operator_role.json
+ tags = var.tags
+}
+
+resource "aws_iam_role_policy_attachment" "operator" {
+ role = aws_iam_role.operator.name
+ policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AWSLambdaNetworkConnectorOperatorPolicy"
+}
+
+# IAM reports role and policy writes before they are consistently available to
+# Lambda. Wait before allowing the native Network Connector resource to create
+# any connector.
+resource "time_sleep" "operator_role_propagation" {
+ depends_on = [aws_iam_role_policy_attachment.operator]
+
+ create_duration = "30s"
+
+ triggers = {
+ operator_role_unique_id = aws_iam_role.operator.unique_id
+ operator_trust_policy_sha256 = sha256(aws_iam_role.operator.assume_role_policy)
+ }
+
+ lifecycle {
+ replace_triggered_by = [aws_iam_role_policy_attachment.operator]
+ }
+}
diff --git a/modules/microvm-foundation/network_connector_security_group.tf b/modules/microvm-foundation/network_connector_security_group.tf
new file mode 100644
index 0000000000..7d88e37d2d
--- /dev/null
+++ b/modules/microvm-foundation/network_connector_security_group.tf
@@ -0,0 +1,40 @@
+# A connector gets its own no-ingress security group. Route tables and NACLs on
+# the caller-selected subnets determine actual destinations reachable through
+# the IPv4 or dual-stack egress rules.
+resource "aws_security_group" "connector" {
+ #checkov:skip=CKV2_AWS_5:The security group is consumed by the Lambda Network Connector rather than by a Terraform-native ENI resource.
+ for_each = var.network_connectors
+
+ name = "microvm-${each.value.name}-${var.aws_region}"
+ description = "Outbound egress for the ${each.value.name} Lambda MicroVM Network Connector"
+ vpc_id = each.value.vpc_id
+
+ tags = merge(var.tags, {
+ Name = "microvm-${each.value.name}-${var.aws_region}"
+ })
+}
+
+resource "aws_vpc_security_group_egress_rule" "ipv4" {
+ #checkov:skip=CKV_AWS_382:The connector requires outbound access; subnet routes and NACLs provide the network destination boundary.
+ for_each = var.network_connectors
+
+ security_group_id = aws_security_group.connector[each.key].id
+ description = "Lambda MicroVM connector IPv4 egress"
+ ip_protocol = "-1"
+ cidr_ipv4 = "0.0.0.0/0"
+ tags = var.tags
+}
+
+resource "aws_vpc_security_group_egress_rule" "ipv6" {
+ #checkov:skip=CKV_AWS_382:Dual-stack connector egress is intentional; subnet routes and NACLs provide the network destination boundary.
+ for_each = {
+ for connector_key, connector in var.network_connectors :
+ connector_key => connector if connector.network_protocol == "DualStack"
+ }
+
+ security_group_id = aws_security_group.connector[each.key].id
+ description = "Lambda MicroVM connector IPv6 egress"
+ ip_protocol = "-1"
+ cidr_ipv6 = "::/0"
+ tags = var.tags
+}
diff --git a/modules/microvm-foundation/outputs.tf b/modules/microvm-foundation/outputs.tf
new file mode 100644
index 0000000000..9fd52ab4bf
--- /dev/null
+++ b/modules/microvm-foundation/outputs.tf
@@ -0,0 +1,34 @@
+output "artifact_bucket_name" {
+ description = "Name of the regional S3 bucket used for Lambda MicroVM build artifacts."
+ value = aws_s3_bucket.artifacts.id
+}
+
+output "artifact_bucket_arn" {
+ description = "ARN of the regional S3 bucket used for Lambda MicroVM build artifacts."
+ value = aws_s3_bucket.artifacts.arn
+}
+
+output "artifact_prefix" {
+ description = "Bucket prefix to which the MicroVM image publisher uploads content-addressed build artifacts."
+ value = local.artifact_prefix
+}
+
+output "build_role_arn" {
+ description = "ARN of the Lambda-trusted role used during MicroVM image builds."
+ value = aws_iam_role.build.arn
+}
+
+output "usage_policy_arn" {
+ description = "ARN of the reusable regional policy for operating MicroVM images in the reserved namespace and passing their Network Connectors."
+ value = aws_iam_policy.usage.arn
+}
+
+output "connector_arns" {
+ description = "Map of connector key to the ARN of each Lambda Network Connector."
+ value = local.connector_arns
+}
+
+output "security_group_ids" {
+ description = "Map of connector key to its dedicated no-ingress security group ID."
+ value = { for connector_key, security_group in aws_security_group.connector : connector_key => security_group.id }
+}
diff --git a/modules/microvm-foundation/storage.tf b/modules/microvm-foundation/storage.tf
new file mode 100644
index 0000000000..73ff52e172
--- /dev/null
+++ b/modules/microvm-foundation/storage.tf
@@ -0,0 +1,103 @@
+# Lambda MicroVM image source artifacts must be stored in an S3 bucket in the
+# same region as the image. A separate helper deployment owns the bucket in each
+# supported region.
+resource "aws_s3_bucket" "artifacts" {
+ #checkov:skip=CKV_AWS_145:SSE-S3 protects ephemeral content-addressed build inputs; this helper has no CMK artifact contract.
+ #checkov:skip=CKV_AWS_144:Lambda MicroVM builds require same-region artifacts, so this regional bucket intentionally has no cross-region replication.
+ #checkov:skip=CKV_AWS_18:CloudTrail records control-plane access and the bucket contains short-lived build inputs; separate S3 access logging is not required.
+ #checkov:skip=CKV2_AWS_62:The publisher uploads artifacts synchronously and no event-driven consumer requires S3 notifications.
+ bucket = var.artifact_bucket_name
+ tags = var.tags
+}
+
+resource "aws_s3_bucket_ownership_controls" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+
+ rule {
+ object_ownership = "BucketOwnerEnforced"
+ }
+}
+
+resource "aws_s3_bucket_versioning" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+
+ versioning_configuration {
+ status = "Enabled"
+ }
+}
+
+resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+
+ rule {
+ apply_server_side_encryption_by_default {
+ sse_algorithm = "AES256"
+ }
+ }
+}
+
+resource "aws_s3_bucket_public_access_block" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+ skip_destroy = true
+}
+
+resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+
+ rule {
+ id = "expire-microvm-build-artifacts"
+
+ # The bucket is dedicated to MicroVM build artifacts, so lifecycle cleanup
+ # applies to every object, including abandoned uploads outside the expected
+ # publisher prefix.
+ filter {}
+
+ expiration {
+ days = var.artifact_retention_days
+ }
+
+ noncurrent_version_expiration {
+ noncurrent_days = var.artifact_retention_days
+ }
+
+ abort_incomplete_multipart_upload {
+ days_after_initiation = 7
+ }
+
+ status = "Enabled"
+ }
+
+ depends_on = [aws_s3_bucket_versioning.artifacts]
+}
+
+data "aws_iam_policy_document" "artifact_bucket" {
+ statement {
+ sid = "DenyInsecureTransport"
+ effect = "Deny"
+ actions = ["s3:*"]
+ resources = [
+ aws_s3_bucket.artifacts.arn,
+ "${aws_s3_bucket.artifacts.arn}/*",
+ ]
+
+ principals {
+ type = "*"
+ identifiers = ["*"]
+ }
+
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+ values = ["false"]
+ }
+ }
+}
+
+resource "aws_s3_bucket_policy" "artifacts" {
+ bucket = aws_s3_bucket.artifacts.id
+ policy = data.aws_iam_policy_document.artifact_bucket.json
+}
diff --git a/modules/microvm-foundation/usage_policy.tf b/modules/microvm-foundation/usage_policy.tf
new file mode 100644
index 0000000000..8abe004bee
--- /dev/null
+++ b/modules/microvm-foundation/usage_policy.tf
@@ -0,0 +1,58 @@
+# Consumer modules can attach this policy to a control-plane role they own.
+# This helper deliberately leaves the managed policy unattached.
+data "aws_iam_policy_document" "usage" {
+ statement {
+ sid = "UseConfiguredMicrovmImages"
+ effect = "Allow"
+ actions = [
+ "lambda:CreateMicrovmAuthToken",
+ "lambda:GetMicrovm",
+ "lambda:GetMicrovmImage",
+ "lambda:GetMicrovmImageVersion",
+ "lambda:ListMicrovmImageVersions",
+ "lambda:ResumeMicrovm",
+ "lambda:RunMicrovm",
+ "lambda:SuspendMicrovm",
+ "lambda:TerminateMicrovm",
+ ]
+ resources = [local.image_arn_pattern]
+ }
+
+ #checkov:skip=CKV_AWS_111:ListMicrovms and ListMicrovmImages do not support resource-level permissions.
+ #checkov:skip=CKV_AWS_356:Lambda MicroVM account-level list actions require Resource '*'.
+ statement {
+ sid = "DiscoverMicrovmRuntimeState"
+ effect = "Allow"
+ actions = [
+ "lambda:ListMicrovmImages",
+ "lambda:ListMicrovms",
+ ]
+ resources = ["*"]
+ }
+
+ statement {
+ sid = "ReadConfiguredNetworkConnectors"
+ effect = "Allow"
+ actions = ["lambda:GetNetworkConnector"]
+ resources = values(local.connector_arns)
+ }
+
+ #checkov:skip=CKV_AWS_111:PassNetworkConnector and ListNetworkConnectors do not support resource-level permissions.
+ #checkov:skip=CKV_AWS_356:Lambda requires Resource '*' for PassNetworkConnector and the account-level list operation.
+ statement {
+ sid = "PassAndDiscoverNetworkConnectors"
+ effect = "Allow"
+ actions = [
+ "lambda:ListNetworkConnectors",
+ "lambda:PassNetworkConnector",
+ ]
+ resources = ["*"]
+ }
+}
+
+resource "aws_iam_policy" "usage" {
+ name_prefix = var.usage_policy_name_prefix
+ description = "Permissions to discover and operate configured Lambda MicroVM images and to read and pass their regional Network Connectors."
+ policy = data.aws_iam_policy_document.usage.json
+ tags = var.tags
+}
diff --git a/modules/microvm-foundation/variables.tf b/modules/microvm-foundation/variables.tf
new file mode 100644
index 0000000000..72c1db000b
--- /dev/null
+++ b/modules/microvm-foundation/variables.tf
@@ -0,0 +1,147 @@
+variable "aws_region" {
+ type = string
+ description = "AWS region in which to create the Lambda MicroVM prerequisites."
+}
+
+variable "tags" {
+ type = map(string)
+ description = "A map of module-specific tags to apply to resources."
+}
+
+variable "build_policy_name_prefix" {
+ type = string
+ description = "Name prefix for the Lambda MicroVM build policy."
+
+ validation {
+ condition = length(var.build_policy_name_prefix) >= 1 && length(var.build_policy_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.build_policy_name_prefix))
+ error_message = "build_policy_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen."
+ }
+}
+
+variable "usage_policy_name_prefix" {
+ type = string
+ description = "Name prefix for the Lambda MicroVM runtime usage policy."
+
+ validation {
+ condition = length(var.usage_policy_name_prefix) >= 1 && length(var.usage_policy_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.usage_policy_name_prefix))
+ error_message = "usage_policy_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen."
+ }
+}
+
+variable "build_role_name_prefix" {
+ type = string
+ description = "Name prefix for the Lambda MicroVM build role."
+
+ validation {
+ condition = length(var.build_role_name_prefix) >= 1 && length(var.build_role_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.build_role_name_prefix))
+ error_message = "build_role_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen."
+ }
+}
+
+variable "network_connector_operator_role_name_prefix" {
+ type = string
+ 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."
+ }
+}
+
+variable "artifact_bucket_name" {
+ type = string
+ description = "Optional name for the regional MicroVM build-artifact bucket. When null, AWS generates the bucket name."
+ default = null
+ nullable = true
+
+ validation {
+ 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."
+ }
+}
+
+variable "artifact_retention_days" {
+ type = number
+ description = "Number of days to retain current and noncurrent MicroVM build artifacts."
+ default = 30
+
+ validation {
+ condition = var.artifact_retention_days >= 1 && var.artifact_retention_days <= 3650
+ error_message = "artifact_retention_days must be between 1 and 3650."
+ }
+}
+
+variable "image_name_prefix" {
+ type = string
+ description = "IAM namespace prefix reserved for externally published Lambda MicroVM image names. This module does not create or enumerate images."
+
+ validation {
+ condition = (
+ length(var.image_name_prefix) >= 1
+ && length(var.image_name_prefix) <= 62
+ && can(regex("^[a-zA-Z0-9-_]+$", var.image_name_prefix))
+ )
+ error_message = "image_name_prefix must be a 1 to 62 character IAM namespace containing only letters, numbers, hyphens, or underscores; the publisher validates each complete image name."
+ }
+}
+
+variable "ecr_repository_arns" {
+ type = set(string)
+ description = "Optional regional ECR repository ARNs from which MicroVM image builds can pull runner base images."
+ default = []
+}
+
+variable "network_connectors" {
+ type = map(object({
+ name = string
+ vpc_id = string
+ subnet_ids = set(string)
+ network_protocol = optional(string, "IPv4")
+ }))
+ description = "Optional regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity."
+
+ validation {
+ condition = alltrue([
+ for connector in values(var.network_connectors) : (
+ length(connector.name) >= 1
+ && length(connector.name) <= 64
+ && can(regex("^[a-zA-Z0-9_-]+$", connector.name))
+ )
+ ])
+ error_message = "Each network connector name must contain only letters, numbers, hyphens, or underscores and be at most 64 characters."
+ }
+
+ validation {
+ condition = (
+ length(distinct([for connector in values(var.network_connectors) : connector.name])) == length(var.network_connectors)
+ )
+ error_message = "Each network connector name must be unique within the region."
+ }
+
+ validation {
+ condition = alltrue([
+ for connector in values(var.network_connectors) : can(regex("^vpc-[0-9a-f]+$", connector.vpc_id))
+ ])
+ error_message = "Each network connector vpc_id must be a valid VPC ID."
+ }
+
+ validation {
+ condition = alltrue([
+ for connector in values(var.network_connectors) : (
+ length(connector.subnet_ids) >= 1
+ && length(connector.subnet_ids) <= 16
+ && alltrue([
+ for subnet_id in connector.subnet_ids : can(regex("^subnet-[0-9a-f]+$", subnet_id))
+ ])
+ )
+ ])
+ error_message = "Each network connector must contain 1 to 16 valid subnet IDs."
+ }
+
+ validation {
+ condition = alltrue([
+ for connector in values(var.network_connectors) : contains(["IPv4", "DualStack"], connector.network_protocol)
+ ])
+ error_message = "Each network connector network_protocol must be IPv4 or DualStack."
+ }
+}
diff --git a/modules/microvm-foundation/versions.tf b/modules/microvm-foundation/versions.tf
new file mode 100644
index 0000000000..ccc4e5a4ef
--- /dev/null
+++ b/modules/microvm-foundation/versions.tf
@@ -0,0 +1,14 @@
+terraform {
+ required_version = ">= 1.4.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.61"
+ }
+ time = {
+ source = "hashicorp/time"
+ version = ">= 0.13"
+ }
+ }
+}
diff --git a/modules/multi-runner/.terraform.lock.hcl b/modules/multi-runner/.terraform.lock.hcl
new file mode 100644
index 0000000000..9559f5fbd8
--- /dev/null
+++ b/modules/multi-runner/.terraform.lock.hcl
@@ -0,0 +1,71 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "6.63.0"
+ constraints = ">= 6.21.0, >= 6.33.0"
+ hashes = [
+ "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=",
+ "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=",
+ "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611",
+ "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d",
+ "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a",
+ "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1",
+ "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d",
+ "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c",
+ "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb",
+ "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9",
+ "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd",
+ "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb",
+ "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925",
+ "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04",
+ "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54",
+ "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/null" {
+ version = "3.3.1"
+ constraints = "~> 3.0, ~> 3.2"
+ hashes = [
+ "h1:TuxJq10DVnRP7c5HBZPyyvQGcckNVfijyU1eXEu5e4M=",
+ "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=",
+ "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44",
+ "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451",
+ "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05",
+ "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3",
+ "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7",
+ "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1",
+ "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be",
+ "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891",
+ "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5",
+ "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610",
+ "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+ version = "3.9.0"
+ constraints = "~> 3.0"
+ hashes = [
+ "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=",
+ "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=",
+ "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1",
+ "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea",
+ "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f",
+ "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0",
+ "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61",
+ "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc",
+ "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e",
+ "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef",
+ "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b",
+ "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257",
+ "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04",
+ ]
+}
diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md
index 61a558389f..635e078570 100644
--- a/modules/multi-runner/README.md
+++ b/modules/multi-runner/README.md
@@ -4,6 +4,8 @@
This module creates many runners with one or more GitHub Apps. The module utilizes the internal modules and deploys parts of the stack for each runner defined.
+Terraform 1.4 or later is required. Terraform 1.3 and earlier are no longer supported by this module.
+
### GitHub App round-robin
To distribute GitHub API rate limit usage, this module supports configuring multiple GitHub Apps via the `additional_github_apps` variable. The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly select an app for each API call, spreading the load across all configured apps.
@@ -18,13 +20,13 @@ The **webhook lambda** does not participate in round-robin: it only validates in
The module takes a configuration as input containing a matcher for the labels. The [webhook](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/webhook/) lambda is using the configuration to delegate events based on the labels in the workflow job and sent them to a dedicated queue based on the configuration. Events on each queue are processed by a dedicated lambda per configuration to scale runners.
-> **Experimental v2 configuration:** Set `experimental_features = ["multi-runner-v2"]` before using the provider-boundary inputs. Their schema may change during the experimental window; the acknowledgement flag will become a deprecated no-op for one release when the feature graduates.
-
For each configuration:
- When enabled, the [distribution syncer](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/runner-binaries-syncer/) is deployed for each unique combination of OS and architecture.
- For each configuration a queue is created and [runner module](https://github-aws-runners.github.io/terraform-aws-github-runner/modules/internal/runners/) is deployed
+Experimental runner configurations may select the Lambda MicroVM provider with `compute_provider.aws.microvm`. MicroVM lanes use Linux ARM64 ephemeral runners with JIT configuration and do not participate in EC2 runner-binary synchronization.
+
## Matching
Matching of the configuration is done based on the labels specified in labelMatchers configuration. The webhook is processing the `workflow_job` event and match the labels against the labels specified in labelMatchers configuration in the order of configuration with exact-match true first, followed by all exact matches false.
@@ -96,48 +98,12 @@ module "multi-runner" {
}
```
-### Provider-boundary v2 configuration
-
-The v2 form of `multi_runner_config` keeps runner, Lambda, orchestration, SSM, observability, and compute-provider settings under one configuration entry. The webhook matcher is configured under `orchestration_provider.webhook.matcherConfig`.
-
-```hcl
-multi_runner_config = {
- "linux-x64" = {
- tags = {
- Environment = "production"
- }
-
- runner = {
- os = "linux"
- architecture = "x64"
- extra_labels = ["large"]
- }
-
- orchestration_provider = {
- webhook = {
- matcherConfig = {
- labelMatchers = [["self-hosted", "linux", "x64", "large"]]
- }
- }
- }
-
- compute_provider = {
- aws = {
- ec2 = {
- instance_types = ["m5.large"]
- }
- }
- }
- }
-}
-```
-
## Requirements
| Name | Version |
|------|---------|
-| [terraform](#requirement\_terraform) | >= 1.3 |
+| [terraform](#requirement\_terraform) | >= 1.4 |
| [aws](#requirement\_aws) | >= 6.33 |
| [random](#requirement\_random) | ~> 3.0 |
@@ -145,8 +111,9 @@ multi_runner_config = {
| Name | Version |
|------|---------|
-| [aws](#provider\_aws) | >= 6.33 |
-| [random](#provider\_random) | ~> 3.0 |
+| [aws](#provider\_aws) | 6.63.0 |
+| [random](#provider\_random) | 3.9.0 |
+| [terraform](#provider\_terraform) | n/a |
## Modules
@@ -155,6 +122,7 @@ multi_runner_config = {
| [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a |
| [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a |
| [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a |
+| [runner\_configs](#module\_runner\_configs) | ../runner-config | n/a |
| [runners](#module\_runners) | ../runners | n/a |
| [ssm](#module\_ssm) | ../ssm | n/a |
| [webhook](#module\_webhook) | ../webhook | n/a |
@@ -168,6 +136,8 @@ multi_runner_config = {
| [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource |
| [aws_sqs_queue_policy.build_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource |
| [random_string.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource |
+| [terraform_data.validate_v1](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
+| [terraform_data.validate_v2](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
| [aws_iam_policy_document.deny_insecure_transport](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
## Inputs
@@ -192,9 +162,9 @@ multi_runner_config = {
| [experimental\_features](#input\_experimental\_features) | Explicit acknowledgement for opt-in features whose schemas may changeobject({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}) | n/a | yes |
+| [github\_app](#input\_github\_app) | GitHub app parameters for the stable v1 interface, see your github app.object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}) | `{}` | no |
| [global\_config](#input\_global\_config) | Global defaults shared by all runner lanes.object({
tags = optional(map(string), {})
roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})
}) | `{}` | no |
-| [global\_config\_compute\_provider](#input\_global\_config\_compute\_provider) | Global compute-provider configuration shared by all runner lanes.object({
selections = optional(map(object({
namespace = string
type = string
})), null)
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
runner_deregistration = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_handler = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_notification_watcher = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})
}) | `{}` | no |
+| [global\_config\_compute\_provider](#input\_global\_config\_compute\_provider) | Global compute-provider configuration shared by all runner lanes.object({
selections = optional(map(object({
namespace = string
type = string
})), null)
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
runner_deregistration = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_handler = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_notification_watcher = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
microvm = optional(object({
image_arn = optional(string, null)
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)
}), {})
}), {})
}), {})
}), {})
}) | `{}` | no |
| [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.object({
app = optional(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}), null)
additional_apps = optional(list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}) | `{}` | no |
| [global\_config\_lambda](#input\_global\_config\_lambda) | Global Lambda configuration shared by all runner lanes.object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}) | `{}` | no |
| [global\_config\_observability](#input\_global\_config\_observability) | Global observability configuration shared by all runner lanes.object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(object({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
}) | `{}` | no |
@@ -220,7 +190,7 @@ multi_runner_config = {
| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no |
| [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no |
| [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. | object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}) | `{}` | no |
-| [multi\_runner\_config](#input\_multi\_runner\_config) | Accepts either the stable v1 runner configuration shape or the provider-boundary v2 shape. Entries with `runner_config` use the v1 shape; entries without `runner_config` use the v2 shape. A v2 entry does not need matcher configuration. A v2 entry must be acknowledged with `experimental_features = ["multi-runner-v2"]`; the v2 shape is experimental and may change before graduation.map(object({
# V1 contract
runner_config = optional(object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
}), null)
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
# V2 Contract
tags = optional(map(string), {})
runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})
lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})
orchestration_provider = optional(object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
organization_runners = optional(bool, false)
}), {})
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
}), null)
queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})
lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
}), {})
ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})
observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})
compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
}), {})
}), {})
})) | n/a | yes |
+| [multi\_runner\_config](#input\_multi\_runner\_config) | Accepts either the stable v1 runner configuration shape or the provider-boundary v2 shape. Entries with `runner_config` use the v1 shape; entries without `runner_config` use the v2 shape. A v2 entry does not need matcher configuration. A v2 entry must be acknowledged with `experimental_features = ["multi-runner-v2"]`; the v2 shape is experimental and may change before graduation.map(object({
# V1 contract
runner_config = optional(object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
}), null)
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
# V2 Contract
tags = optional(map(string), {})
runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})
lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})
orchestration_provider = optional(object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
organization_runners = optional(bool, false)
}), {})
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
}), null)
queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})
lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
}), {})
ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})
observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})
compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
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 |
@@ -248,13 +218,13 @@ multi_runner_config = {
| [scale\_up\_lambda\_memory\_size](#input\_scale\_up\_lambda\_memory\_size) | Memory size limit in MB for scale\_up lambda. | `number` | `512` | no |
| [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. | object({
root = optional(string, "github-action-runners")
app = optional(string, "app")
runners = optional(string, "runners")
webhook = optional(string, "webhook")
}) | `{}` | no |
| [state\_event\_rule\_binaries\_syncer](#input\_state\_event\_rule\_binaries\_syncer) | Option to disable EventBridge Lambda trigger for the binary syncer, useful to stop automatic updates of binary distribution | `string` | `"ENABLED"` | no |
-| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes |
+| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which stable v1 action runners will be launched. Omit when using the experimental v2 interface. | `list(string)` | `null` | no |
| [syncer\_lambda\_s3\_key](#input\_syncer\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no |
| [syncer\_lambda\_s3\_object\_version](#input\_syncer\_lambda\_s3\_object\_version) | S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no |
| [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no |
| [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. | object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}) | `{}` | no |
| [user\_agent](#input\_user\_agent) | User agent used for API calls by lambda functions. | `string` | `"github-aws-runners"` | no |
-| [vpc\_id](#input\_vpc\_id) | The VPC for security groups of the action runners. | `string` | n/a | yes |
+| [vpc\_id](#input\_vpc\_id) | The VPC for security groups of stable v1 action runners. Omit when using the experimental v2 interface. | `string` | `null` | no |
| [webhook\_lambda\_apigateway\_access\_log\_settings](#input\_webhook\_lambda\_apigateway\_access\_log\_settings) | Access log settings for webhook API gateway. | object({
destination_arn = string
format = string
}) | `null` | no |
| [webhook\_lambda\_memory\_size](#input\_webhook\_lambda\_memory\_size) | Memory size limit in MB for webhook lambda. | `number` | `256` | no |
| [webhook\_lambda\_s3\_key](#input\_webhook\_lambda\_s3\_key) | S3 key for webhook lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no |
@@ -270,6 +240,7 @@ multi_runner_config = {
| [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a |
| [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a |
| [runners\_map](#output\_runners\_map) | n/a |
+| [runners\_map\_v2](#output\_runners\_map\_v2) | n/a |
| [ssm\_parameters](#output\_ssm\_parameters) | n/a |
| [webhook](#output\_webhook) | n/a |
diff --git a/modules/multi-runner/config.experimental.resolved.tf b/modules/multi-runner/config.experimental.resolved.tf
index b7e2e26ab0..ac6f482970 100644
--- a/modules/multi-runner/config.experimental.resolved.tf
+++ b/modules/multi-runner/config.experimental.resolved.tf
@@ -480,6 +480,54 @@ locals {
}
tags = merge(local.normalized_config.compute_provider.aws.ec2.tags, v.compute_provider.aws.ec2.tags)
})
+ microvm = v.compute_provider.aws.microvm == null ? null : merge(v.compute_provider.aws.microvm, {
+ image_arn = try(coalesce(v.compute_provider.aws.microvm.image_arn, local.normalized_config.compute_provider.aws.microvm.image_arn), null)
+ image_version = try(coalesce(v.compute_provider.aws.microvm.image_version, local.normalized_config.compute_provider.aws.microvm.image_version), null)
+ ingress_network_connectors = v.compute_provider.aws.microvm.ingress_network_connectors != null ? (
+ v.compute_provider.aws.microvm.ingress_network_connectors
+ ) : local.normalized_config.compute_provider.aws.microvm.ingress_network_connectors
+ egress_network_connectors = v.compute_provider.aws.microvm.egress_network_connectors != null ? (
+ v.compute_provider.aws.microvm.egress_network_connectors
+ ) : local.normalized_config.compute_provider.aws.microvm.egress_network_connectors
+ cloudwatch_agent = {
+ enabled = coalesce(
+ v.compute_provider.aws.microvm.cloudwatch_agent.enabled,
+ local.normalized_config.compute_provider.aws.microvm.cloudwatch_agent.enabled,
+ )
+ config = try(coalesce(
+ v.compute_provider.aws.microvm.cloudwatch_agent.config,
+ local.normalized_config.compute_provider.aws.microvm.cloudwatch_agent.config,
+ ), null)
+ }
+ log_files = v.compute_provider.aws.microvm.log_files != null ? (
+ v.compute_provider.aws.microvm.log_files
+ ) : local.normalized_config.compute_provider.aws.microvm.log_files
+ environment_variables = merge(
+ local.normalized_config.compute_provider.aws.microvm.environment_variables,
+ v.compute_provider.aws.microvm.environment_variables,
+ )
+ iam = {
+ resource_arns = {
+ images = v.compute_provider.aws.microvm.iam.resource_arns.images != null ? (
+ v.compute_provider.aws.microvm.iam.resource_arns.images
+ ) : local.normalized_config.compute_provider.aws.microvm.iam.resource_arns.images
+ }
+ additional_policy_json = {
+ scale_up = try(coalesce(
+ v.compute_provider.aws.microvm.iam.additional_policy_json.scale_up,
+ local.normalized_config.compute_provider.aws.microvm.iam.additional_policy_json.scale_up,
+ ), null)
+ }
+ managed_policies = {
+ scale_up = v.compute_provider.aws.microvm.iam.managed_policies.scale_up != null ? (
+ v.compute_provider.aws.microvm.iam.managed_policies.scale_up
+ ) : local.normalized_config.compute_provider.aws.microvm.iam.managed_policies.scale_up
+ pool = v.compute_provider.aws.microvm.iam.managed_policies.pool != null ? (
+ v.compute_provider.aws.microvm.iam.managed_policies.pool
+ ) : local.normalized_config.compute_provider.aws.microvm.iam.managed_policies.pool
+ }
+ }
+ })
}
}
})
diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf
index 07df28e2e7..16b6f45d4e 100644
--- a/modules/multi-runner/config.experimental.translation.tf
+++ b/modules/multi-runner/config.experimental.translation.tf
@@ -301,6 +301,30 @@ locals {
}
}
}
+ 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
+ }
+ }
+ }
}
}
@@ -552,6 +576,30 @@ 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
+ }
+ }
+ }
}
}
}
diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf
index 7c4a9807d1..4c7d4a6cd3 100644
--- a/modules/multi-runner/outputs.tf
+++ b/modules/multi-runner/outputs.tf
@@ -21,6 +21,18 @@ output "runners_map" {
}
}
+output "runners_map_v2" {
+ value = { for runner_key, runner in module.runner_configs : runner_key => {
+ runner = runner.runner
+ orchestration_provider = runner.orchestration_provider
+ scale_up = runner.scale_up
+ scale_down = runner.scale_down
+ pool = runner.pool
+ provider = runner.provider
+ }
+ }
+}
+
output "binaries_syncer_map" {
value = { for runner_binary_key, runner_binary in module.runner_binaries : runner_binary_key => {
lambda = runner_binary.lambda
diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf
new file mode 100644
index 0000000000..4e8652aa00
--- /dev/null
+++ b/modules/multi-runner/runners.experimental.tf
@@ -0,0 +1,38 @@
+module "runner_configs" {
+ source = "../runner-config"
+ for_each = {
+ for runner_key, runner_config in local.effective_config.multi_runner_config :
+ runner_key => runner_config if local.use_v2_config
+ }
+
+ aws_region = var.aws_region
+ aws_partition = var.aws_partition
+ prefix = "${var.prefix}-${each.key}"
+
+ tags = merge(
+ each.value.tags,
+ { "ghr:environment" = var.prefix },
+ )
+ runner = each.value.runner
+ github = merge(each.value.github, {
+ app_parameters = local.github_app_parameters
+ })
+ lambda = each.value.lambda
+ orchestration_provider = {
+ webhook = each.value.orchestration_provider.webhook == null ? null : {
+ runner = each.value.orchestration_provider.webhook.runner
+ github = each.value.orchestration_provider.webhook.github
+ queue = merge(each.value.orchestration_provider.webhook.queue, {
+ build = {
+ arn = aws_sqs_queue.queued_builds[each.key].arn
+ url = aws_sqs_queue.queued_builds[each.key].url
+ }
+ })
+ lambda = each.value.orchestration_provider.webhook.lambda
+ job_retry = each.value.orchestration_provider.webhook.job_retry
+ }
+ }
+ ssm = each.value.ssm
+ observability = each.value.observability
+ compute_provider = each.value.compute_provider
+}
diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf
index 61c5f57583..5e0bc44022 100644
--- a/modules/multi-runner/runners.tf
+++ b/modules/multi-runner/runners.tf
@@ -1,6 +1,9 @@
module "runners" {
- source = "../runners"
- for_each = local.effective_config.multi_runner_config
+ source = "../runners"
+ for_each = {
+ for runner_key, runner_config in local.effective_config.multi_runner_config :
+ runner_key => runner_config if !local.use_v2_config
+ }
aws_region = var.aws_region
aws_partition = var.aws_partition
vpc_id = each.value.compute_provider.aws.ec2.vpc_id
diff --git a/modules/multi-runner/tests/config-resolution.tftest.hcl b/modules/multi-runner/tests/config-resolution.tftest.hcl
index efb117ed7f..a4a6f2af00 100644
--- a/modules/multi-runner/tests/config-resolution.tftest.hcl
+++ b/modules/multi-runner/tests/config-resolution.tftest.hcl
@@ -59,30 +59,9 @@ mock_provider "random" {}
mock_provider "null" {}
variables {
- aws_region = "eu-west-1"
- vpc_id = "vpc-stable"
- subnet_ids = ["subnet-stable"]
-
- github_app = {
- key_base64_ssm = {
- arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/key"
- name = "/tests/github-app/key"
- }
- id_ssm = {
- arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/id"
- name = "/tests/github-app/id"
- }
- webhook_secret_ssm = {
- arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/webhook-secret"
- name = "/tests/github-app/webhook-secret"
- }
- }
-
- lambda_s3_bucket = "test-lambda-artifacts"
- runners_lambda_zip = "README.md"
- runners_lambda_s3_key = "runners.zip"
- webhook_lambda_s3_key = "webhook.zip"
- syncer_lambda_s3_key = "runner-binaries-syncer.zip"
+ aws_region = "eu-west-1"
+ prefix = "test"
+ aws_partition = "aws"
global_config_github = {
app = {
@@ -152,6 +131,30 @@ run "v1_stable_inputs_translate_into_effective_base" {
command = plan
variables {
+ vpc_id = "vpc-stable"
+ subnet_ids = ["subnet-stable"]
+
+ github_app = {
+ key_base64_ssm = {
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/key"
+ name = "/tests/github-app/key"
+ }
+ id_ssm = {
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/id"
+ name = "/tests/github-app/id"
+ }
+ webhook_secret_ssm = {
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/webhook-secret"
+ name = "/tests/github-app/webhook-secret"
+ }
+ }
+
+ lambda_s3_bucket = "test-lambda-artifacts"
+ runners_lambda_zip = "README.md"
+ runners_lambda_s3_key = "runners.zip"
+ webhook_lambda_s3_key = "webhook.zip"
+ syncer_lambda_s3_key = "runner-binaries-syncer.zip"
+
tags = {
source = "v1"
}
@@ -189,18 +192,29 @@ run "v1_stable_inputs_translate_into_effective_base" {
&& local.resolved_config.multi_runner_config["stable"].runner.group_name == "v1-lane"
&& local.resolved_config.multi_runner_config["stable"].orchestration_provider.webhook.runner.maximum_count == 2
&& toset(local.resolved_config.multi_runner_config["stable"].compute_provider.aws.ec2.instance_types) == toset(["m5.large"])
+ && local.resolved_config.multi_runner_config["stable"].compute_provider.aws.microvm.image_arn == null
+ && local.resolved_config.multi_runner_config["stable"].compute_provider.aws.microvm.cloudwatch_agent.enabled
+ && length(local.resolved_config.multi_runner_config["stable"].compute_provider.aws.microvm.environment_variables) == 0
&& toset(local.effective_config.multi_runner_config["stable"].runner.labels) == toset(["linux", "self-hosted", "x64"])
)
error_message = "Stable v1 inputs must translate into the effective experimental base without leaking v2 globals."
}
+
+ assert {
+ condition = (
+ keys(module.runners) == ["stable"]
+ && length(module.runner_configs) == 0
+ && keys(output.runners_map) == ["stable"]
+ && length(output.runners_map_v2) == 0
+ )
+ error_message = "Stable v1 configurations must route through module.runners and not the experimental runner-config module."
+ }
}
run "v2_inputs_resolve_lane_over_global" {
command = plan
variables {
- experimental_features = ["multi-runner-v2"]
-
tags = {
source = "v1-must-not-leak"
}
@@ -372,4 +386,186 @@ run "v2_inputs_resolve_lane_over_global" {
)
error_message = "v2 inputs must resolve lane overrides before v2 global defaults."
}
+
+ assert {
+ condition = (
+ length(module.runners) == 0
+ && keys(module.runner_configs) == ["lane"]
+ && length(output.runners_map) == 0
+ && keys(output.runners_map_v2) == ["lane"]
+ )
+ error_message = "Experimental v2 configurations must route through module.runner_configs and skip the legacy runners module."
+ }
+}
+
+run "v2_inputs_do_not_require_legacy_arguments" {
+ command = plan
+
+ variables {
+ global_config_compute_provider = {
+ aws = {
+ ec2 = {
+ vpc_id = "vpc-v2"
+ subnet_ids = ["subnet-v2"]
+ runner_binaries = {
+ enabled = false
+ }
+ }
+ }
+ }
+ multi_runner_config = {
+ lane = {
+ orchestration_provider = {
+ webhook = {
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "linux", "x64"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ local.use_v2_config
+ && keys(module.runner_configs) == ["lane"]
+ && length(module.runners) == 0
+ && local.resolved_config.multi_runner_config["lane"].compute_provider.aws.ec2.vpc_id == "vpc-v2"
+ )
+ error_message = "The v2 interface must work without the stable v1 GitHub App, VPC, subnet, or runner configuration inputs."
+ }
+}
+
+run "v2_microvm_inputs_route_to_microvm_provider" {
+ command = plan
+
+ variables {
+ global_config = {
+ runner = {
+ os = "linux"
+ architecture = "arm64"
+ }
+ }
+
+ global_config_github = {
+ app = {
+ key_base64 = "experimental-app-key"
+ id = "experimental-app-id"
+ webhook_secret = "experimental-webhook-secret"
+ }
+ }
+
+ global_config_lambda = {
+ artifact = {
+ s3 = {
+ bucket = "global-lambda-artifacts"
+ }
+ }
+ }
+
+ global_config_orchestration_provider = {
+ webhook = {
+ eventbridge = {
+ enabled = false
+ }
+ runner = {
+ ephemeral = true
+ jit_config_enabled = true
+ }
+ lambda = {
+ artifact = {
+ s3 = {
+ key = "global-runners.zip"
+ }
+ }
+ webhook = {
+ artifact = {
+ s3 = {
+ key = "global-webhook.zip"
+ }
+ }
+ }
+ }
+ }
+ }
+
+ global_config_ssm = {
+ housekeeper = {
+ lambda = {
+ artifact = {
+ s3 = {
+ key = "global-housekeeper.zip"
+ }
+ }
+ }
+ }
+ }
+
+ global_config_compute_provider = {
+ aws = {
+ microvm = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:global"
+ image_version = "7"
+ }
+ }
+ }
+
+ multi_runner_config = {
+ microvm = {
+ runner = {
+ name_prefix = "microvm-"
+ }
+ orchestration_provider = {
+ webhook = {
+ matcherConfig = {
+ labelMatchers = [["microvm"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ microvm = {
+ image_version = "8"
+ }
+ }
+ }
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ local.use_v2_config
+ && keys(local.resolved_config.multi_runner_config) == ["microvm"]
+ && local.resolved_config.multi_runner_config["microvm"].runner.os == "linux"
+ && local.resolved_config.multi_runner_config["microvm"].runner.architecture == "arm64"
+ && local.resolved_config.multi_runner_config["microvm"].compute_provider.aws.ec2 == null
+ && local.resolved_config.multi_runner_config["microvm"].compute_provider.aws.microvm.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:global"
+ && local.resolved_config.multi_runner_config["microvm"].compute_provider.aws.microvm.image_version == "8"
+ && local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.s3.key == "global-webhook.zip"
+ )
+ error_message = "Experimental MicroVM lanes must resolve Linux ARM64 settings, inherit global provider values, and place the webhook artifact key under lambda.webhook.artifact."
+ }
+
+ assert {
+ condition = (
+ length(module.runners) == 0
+ && keys(module.runner_configs) == ["microvm"]
+ && output.runners_map_v2["microvm"].provider.aws.ec2 == null
+ && output.runners_map_v2["microvm"].provider.aws.microvm.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:global"
+ && output.runners_map_v2["microvm"].provider.aws.microvm.image_version == "8"
+ )
+ error_message = "Experimental MicroVM lanes must route through module.runner_configs and expose the MicroVM provider contract without an EC2 provider."
+ }
}
diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf
new file mode 100644
index 0000000000..4be542eb59
--- /dev/null
+++ b/modules/multi-runner/validations.tf
@@ -0,0 +1,112 @@
+locals {
+ common_validation_errors = concat(
+ alltrue([
+ for app in var.additional_github_apps :
+ (app.key_base64 != null || app.key_base64_ssm != null) &&
+ (app.id != null || app.id_ssm != null)
+ ]) ? [] : ["Each additional GitHub app must provide either key_base64 or key_base64_ssm, and either id or id_ssm."],
+ contains(["STANDARD", "INFREQUENT_ACCESS"], var.log_class) ? [] : ["`log_class` must be either `STANDARD` or `INFREQUENT_ACCESS`."],
+ contains(["first", "random", "all"], var.queue_selection_strategy) ? [] : ["`queue_selection_strategy` value not valid. Valid values are 'first', 'random', 'all'."],
+ contains(["silly", "trace", "debug", "info", "warn", "error", "fatal"], var.log_level) ? [] : ["`log_level` value not valid. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'."],
+ contains(["arm64", "x86_64"], var.lambda_architecture) ? [] : ["`lambda_architecture` value is not valid, valid values are: `arm64` and `x86_64`."],
+ contains(["ENABLED", "DISABLED", "ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS"], var.state_event_rule_binaries_syncer) ? [] : ["`state_event_rule_binaries_syncer` value is not valid, valid values are: `ENABLED`, `DISABLED`, `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`."],
+ var.queue_encryption == null || var.queue_encryption.sqs_managed_sse_enabled != null && var.queue_encryption.kms_master_key_id == null && var.queue_encryption.kms_data_key_reuse_period_seconds == null || var.queue_encryption.sqs_managed_sse_enabled == null && var.queue_encryption.kms_master_key_id != null ? [] : ["Invalid configuration for `queue_encryption`. Valid configurations are encryption disabled, enabled via SSE. Or encryption via KMS."],
+ contains(["Standard", "Advanced"], var.matcher_config_parameter_store_tier) ? [] : ["`matcher_config_parameter_store_tier` value is not valid, valid values are: `Standard`, and `Advanced`."],
+ !var.iam_overrides.override_instance_profile || var.iam_overrides.instance_profile_name != null ? [] : ["instance_profile_name must be provided when override_instance_profile is true."],
+ !var.iam_overrides.override_runner_role || var.iam_overrides.runner_role_arn != null ? [] : ["runner_role_arn must be provided when override_runner_role is true."]
+ )
+}
+
+resource "terraform_data" "validate_v1" {
+ count = local.use_v2_config ? 0 : 1
+
+ lifecycle {
+ precondition {
+ condition = length(local.common_validation_errors) == 0
+ error_message = join("\n", local.common_validation_errors)
+ }
+
+ precondition {
+ condition = (
+ (var.github_app.key_base64 != null || var.github_app.key_base64_ssm != null) &&
+ (var.github_app.id != null || var.github_app.id_ssm != null) &&
+ (var.github_app.webhook_secret != null || var.github_app.webhook_secret_ssm != null) &&
+ var.vpc_id != null &&
+ var.subnet_ids != null &&
+ length(var.multi_runner_config) > 0
+ )
+ error_message = "Stable v1 configuration requires github_app, vpc_id, subnet_ids, and multi_runner_config."
+ }
+ }
+}
+
+resource "terraform_data" "validate_v2" {
+ count = local.use_v2_config ? 1 : 0
+
+ lifecycle {
+ precondition {
+ condition = length(local.common_validation_errors) == 0
+ error_message = join("\n", local.common_validation_errors)
+ }
+
+ precondition {
+ condition = (
+ (
+ try(var.global_config_github.app.key_base64, null) != null ||
+ try(var.global_config_github.app.key_base64_ssm, null) != null
+ ) && (
+ try(var.global_config_github.app.id, null) != null ||
+ try(var.global_config_github.app.id_ssm, null) != null
+ ) && (
+ try(var.global_config_github.app.webhook_secret, null) != null ||
+ try(var.global_config_github.app.webhook_secret_ssm, null) != null
+ )
+ )
+ error_message = "Experimental v2 configuration requires a complete GitHub App under global_config_github.app."
+ }
+
+ precondition {
+ condition = alltrue([
+ for config in local.resolved_config.multi_runner_config : (
+ try(config.orchestration_provider.webhook != null, false)
+ )
+ ])
+ error_message = "Each experimental v2 runner lane requires a webhook provider."
+ }
+
+ precondition {
+ condition = alltrue([
+ for config in local.resolved_config.multi_runner_config : length([
+ for provider_config in [
+ try(config.compute_provider.aws.ec2, null),
+ try(config.compute_provider.aws.microvm, null),
+ ] : provider_config if provider_config != null
+ ]) == 1
+ ])
+ error_message = "Each experimental v2 runner lane requires exactly one compute provider. Supported providers: aws.ec2, aws.microvm."
+ }
+
+ precondition {
+ condition = alltrue([
+ for config in local.resolved_config.multi_runner_config : (
+ !try(config.compute_provider.aws.ec2 != null, false) || (
+ try(length(config.compute_provider.aws.ec2.instance_types) > 0, false) &&
+ try(config.compute_provider.aws.ec2.vpc_id != null, false) &&
+ try(length(config.compute_provider.aws.ec2.subnet_ids) > 0, false)
+ )
+ )
+ ])
+ error_message = "Each experimental v2 EC2 runner lane requires instance_types, vpc_id, and at least one subnet."
+ }
+
+ precondition {
+ condition = alltrue([
+ for config in local.resolved_config.multi_runner_config : (
+ !try(config.compute_provider.aws.microvm != null, false) ||
+ try(config.compute_provider.aws.microvm.image_arn != null, false)
+ )
+ ])
+ error_message = "Each experimental v2 MicroVM runner lane requires image_arn."
+ }
+ }
+}
diff --git a/modules/multi-runner/variables.experimental.compute-provider.tf b/modules/multi-runner/variables.experimental.compute-provider.tf
index 4780a156c3..1684ef8e74 100644
--- a/modules/multi-runner/variables.experimental.compute-provider.tf
+++ b/modules/multi-runner/variables.experimental.compute-provider.tf
@@ -199,6 +199,40 @@ variable "global_config_compute_provider" {
}), {})
}), {})
}), {})
+ microvm = optional(object({
+ image_arn = optional(string, null)
+ 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)
+ }), {})
+ }), {})
+ }), {})
}), {})
})
default = {}
diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf
index bddc0873b4..4f08bc8a8a 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -1,6 +1,8 @@
variable "github_app" {
description = <object({
aws = optional(object({
ec2 = optional(object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
vpc_id = string
subnet_ids = list(string)
overrides = optional(object({
name_runner = optional(string, "")
name_sg = optional(string, "")
}), {})
instance_profile = optional(object({
name = string
}), null)
instance_profile_path = optional(string, null)
binaries_syncer = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
arn = string
id = string
key = string
}), null)
}), {})
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
ebs_optimized = optional(bool, false)
instance_target_capacity_type = optional(string, "spot")
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_types = list(string)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
ssm_enabled = optional(bool, false)
create_service_linked_role_spot = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
managed_security_group_enabled = optional(bool, true)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
key_name = optional(string, null)
additional_security_group_ids = optional(list(string), [])
detailed_monitoring_enabled = optional(bool, false)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
tags = optional(map(string), {})
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
credit_specification = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
associate_public_ipv4_address = optional(bool, false)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
use_dedicated_host = optional(bool, false)
}), null)
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.object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
}) | n/a | yes |
+| [lambda](#input\_lambda) | Common Lambda substrate independent of the selected runner orchestration provider.object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}) | `{}` | no |
+| [observability](#input\_observability) | Logging, tracing, and metrics configuration for control-plane and provider resources.object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(object({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
}) | `{}` | no |
+| [orchestration\_provider](#input\_orchestration\_provider) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply.object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
}) | n/a | yes |
+| [prefix](#input\_prefix) | The prefix used for naming resources. | `string` | `"github-actions"` | no |
+| [runner](#input\_runner) | Provider-neutral GitHub runner configuration.object({
os = optional(string, "linux")
architecture = optional(string, "x64")
disable_default_labels = optional(bool, false)
labels = list(string)
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}) | n/a | yes |
+| [ssm](#input\_ssm) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.object({
paths = object({
root = string
tokens = string
config = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}) | n/a | yes |
+| [tags](#input\_tags) | Base tags added to taggable resources created by this runner configuration. Shared, component, and compute-provider tag maps override matching keys within their documented resource scopes. | `map(string)` | `{}` | no |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [orchestration\_provider](#output\_orchestration\_provider) | Resources grouped under the selected runner orchestration provider. |
+| [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. |
+| [provider](#output\_provider) | Provider-specific resources grouped under the selected provider namespace and type. |
+| [runner](#output\_runner) | Common runner resources. The role is null when an external runner role is used. |
+| [scale\_down](#output\_scale\_down) | Scale-down control-plane resources. Null when webhook orchestration is not configured. |
+| [scale\_up](#output\_scale\_up) | Scale-up control-plane resources. Null when webhook orchestration is not configured. |
+
diff --git a/modules/runner-config/common-config.tf b/modules/runner-config/common-config.tf
new file mode 100644
index 0000000000..660fcc60ab
--- /dev/null
+++ b/modules/runner-config/common-config.tf
@@ -0,0 +1,44 @@
+# Shared control-plane configuration: naming, paths, tags, and normalized values.
+locals {
+ common_tags = var.tags
+ runner_tags = merge(local.common_tags, var.runner.tags)
+ lambda_tags = merge(local.common_tags, var.lambda.tags)
+ observability_log_tags = merge(local.common_tags, var.observability.logs.tags)
+
+ ssm_tags = merge(local.common_tags, var.ssm.tags)
+ ssm_parameter_tags = merge(local.ssm_tags, var.ssm.parameters.tags)
+ ssm_housekeeper_tags = merge(local.ssm_tags, var.ssm.housekeeper.tags)
+ ssm_housekeeper_lambda_tags = merge(local.lambda_tags, var.ssm.tags, var.ssm.housekeeper.tags)
+ ssm_housekeeper_log_tags = merge(local.observability_log_tags, var.ssm.tags, var.ssm.housekeeper.tags)
+
+ lambda_role_path = var.lambda.role.path == null ? "/${var.prefix}/" : var.lambda.role.path
+ runner_role_path = var.runner.iam.path == null ? "/${var.prefix}/" : var.runner.iam.path
+ packaged_runners_lambda_zip = "${path.module}/../../lambdas/functions/control-plane/runners.zip"
+ ssm_housekeeper_artifact_s3_selected = (
+ var.ssm.housekeeper.lambda.artifact.s3 != null
+ )
+ ssm_housekeeper_artifact = {
+ zip = local.ssm_housekeeper_artifact_s3_selected ? null : coalesce(
+ var.ssm.housekeeper.lambda.artifact.zip,
+ local.packaged_runners_lambda_zip,
+ )
+ s3 = {
+ bucket = local.ssm_housekeeper_artifact_s3_selected ? var.lambda.artifact.s3.bucket : null
+ key = try(var.ssm.housekeeper.lambda.artifact.s3.key, null)
+ object_version = try(var.ssm.housekeeper.lambda.artifact.s3.object_version, null)
+ }
+ }
+ kms_key_id = var.ssm.kms_key_id
+ token_path = "${var.ssm.paths.root}/${var.ssm.paths.tokens}"
+ arn_ssm_parameters_path_tokens = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm.paths.root}/${var.ssm.paths.tokens}"
+ arn_ssm_parameters_path_config = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm.paths.root}/${var.ssm.paths.config}"
+
+ parameter_store_tags = jsonencode([
+ for key, value in local.ssm_parameter_tags : {
+ Key = key
+ Value = value
+ }
+ ])
+}
+
+data "aws_caller_identity" "current" {}
diff --git a/modules/runner-config/compute-provider.aws.ec2.tf b/modules/runner-config/compute-provider.aws.ec2.tf
new file mode 100644
index 0000000000..5d053a73dc
--- /dev/null
+++ b/modules/runner-config/compute-provider.aws.ec2.tf
@@ -0,0 +1,37 @@
+module "compute_aws_ec2_trust_policy" {
+ count = local.provider_key == "aws_ec2" ? 1 : 0
+ source = "../compute-providers/aws/ec2/trust-policy"
+
+ additional_trust_policy_json = var.runner.iam.additional_trust_policy_json
+}
+
+module "compute_aws_ec2" {
+ count = local.provider_key == "aws_ec2" ? 1 : 0
+ source = "../compute-providers/aws/ec2"
+
+ aws_partition = var.aws_partition
+ aws_region = var.aws_region
+ prefix = var.prefix
+ tags = var.tags
+
+ config = var.compute_provider.aws.ec2
+ runner = merge(var.runner, {
+ iam = merge(var.runner.iam, {
+ role = local.runner_role
+ managed_policy_arns = local.common_runner_managed_policy_arns
+ })
+ })
+ github = var.github
+ ssm = var.ssm
+ observability = var.observability
+}
+
+moved {
+ from = module.compute_ec2_trust_policy
+ to = module.compute_aws_ec2_trust_policy
+}
+
+moved {
+ from = module.compute_ec2
+ to = module.compute_aws_ec2
+}
diff --git a/modules/runner-config/compute-provider.aws.microvm.tf b/modules/runner-config/compute-provider.aws.microvm.tf
new file mode 100644
index 0000000000..bdfb5a8f30
--- /dev/null
+++ b/modules/runner-config/compute-provider.aws.microvm.tf
@@ -0,0 +1,27 @@
+module "compute_aws_microvm_trust_policy" {
+ count = local.provider_key == "aws_microvm" ? 1 : 0
+ source = "../compute-providers/aws/microvm/trust-policy"
+
+ additional_trust_policy_json = var.runner.iam.additional_trust_policy_json
+}
+
+module "compute_aws_microvm" {
+ count = local.provider_key == "aws_microvm" ? 1 : 0
+ source = "../compute-providers/aws/microvm"
+
+ aws_partition = var.aws_partition
+ aws_region = var.aws_region
+ prefix = var.prefix
+ tags = var.tags
+
+ config = var.compute_provider.aws.microvm
+ runner = merge(var.runner, {
+ iam = merge(var.runner.iam, {
+ role = local.runner_role
+ managed_policy_arns = local.common_runner_managed_policy_arns
+ })
+ })
+ github = var.github
+ ssm = var.ssm
+ observability = var.observability
+}
diff --git a/modules/runner-config/compute-provider.tf b/modules/runner-config/compute-provider.tf
new file mode 100644
index 0000000000..c768c47c22
--- /dev/null
+++ b/modules/runner-config/compute-provider.tf
@@ -0,0 +1,33 @@
+locals {
+ compute_providers = {
+ aws_ec2 = var.compute_provider.aws.ec2
+ aws_microvm = var.compute_provider.aws.microvm
+ }
+
+ discovered_provider_key = one([
+ for provider_key, provider_config in local.compute_providers : provider_key
+ if provider_config != null
+ ])
+ provider_key = var.compute_provider_key != null ? var.compute_provider_key : local.discovered_provider_key
+
+ provider_types = {
+ aws_ec2 = "ec2"
+ aws_microvm = "microvm"
+ }
+
+ provider_type = local.provider_types[local.provider_key]
+
+ provider_assume_role_policies = {
+ aws_ec2 = try(module.compute_aws_ec2_trust_policy[0].assume_role_policy, null)
+ aws_microvm = try(module.compute_aws_microvm_trust_policy[0].assume_role_policy, null)
+ }
+
+ provider_assume_role_policy = local.provider_assume_role_policies[local.provider_key]
+
+ provider_contracts = {
+ aws_ec2 = one(module.compute_aws_ec2[*].provider)
+ aws_microvm = one(module.compute_aws_microvm[*].provider)
+ }
+
+ provider_contract = local.provider_contracts[local.provider_key]
+}
diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf
new file mode 100644
index 0000000000..25994beaba
--- /dev/null
+++ b/modules/runner-config/orchestration-provider.tf
@@ -0,0 +1,73 @@
+locals {
+ orchestration_providers = {
+ for provider_type, provider_config in var.orchestration_provider : provider_type => provider_config
+ if provider_config != null
+ }
+
+ orchestration_provider_type = one(keys(local.orchestration_providers))
+
+ orchestration_provider_enabled = {
+ webhook = local.orchestration_provider_type == "webhook"
+ }
+
+ orchestration_provider_runner_lifecycle = {
+ webhook = one(module.orchestration_webhook[*].runner_lifecycle)
+ }[local.orchestration_provider_type]
+}
+
+module "orchestration_webhook" {
+ source = "../orchestration-providers/webhook"
+ count = local.orchestration_provider_enabled.webhook ? 1 : 0
+
+ aws_partition = var.aws_partition
+ prefix = var.prefix
+ tags = var.tags
+
+ config = var.orchestration_provider.webhook
+ runner = var.runner
+ github = var.github
+ lambda = {
+ artifact = var.lambda.artifact
+ runtime = var.lambda.runtime
+ architecture = var.lambda.architecture
+ subnet_ids = var.lambda.subnet_ids
+ security_group_ids = var.lambda.security_group_ids
+ tags = var.lambda.tags
+ role = {
+ path = local.lambda_role_path
+ permissions_boundary = var.lambda.role.permissions_boundary
+ principals = var.lambda.principals
+ }
+ }
+ ssm = {
+ token_path = local.token_path
+ token_path_arn = local.arn_ssm_parameters_path_tokens
+ config_path = "${var.ssm.paths.root}/${var.ssm.paths.config}"
+ config_path_arn = local.arn_ssm_parameters_path_config
+ kms_key_id = local.kms_key_id
+ parameter_store_tags = local.parameter_store_tags
+ }
+ observability = var.observability
+
+ runner_provider = {
+ type = local.provider_type
+ scale_up = {
+ environment_variables = local.provider_contract.environment_variables.scale_up
+ iam_policy_json = local.provider_contract.policies.scale_up.iam_policy_json
+ additional_iam_policy_json = local.provider_contract.policies.scale_up.additional_iam_policy_json
+ managed_policy = local.provider_contract.policies.scale_up.managed_policy_enabled ? {
+ arn = local.provider_contract.policies.scale_up.managed_policy_arn
+ } : null
+ }
+ scale_down = {
+ environment_variables = local.provider_contract.environment_variables.scale_down
+ iam_policy_json = local.provider_contract.policies.scale_down.iam_policy_json
+ }
+ pool = {
+ environment_variables = local.provider_contract.environment_variables.pool
+ iam_policy_json = local.provider_contract.policies.pool.iam_policy_json
+ managed_policy_enabled = local.provider_contract.policies.pool.managed_policy_enabled
+ managed_policy_arn = local.provider_contract.policies.pool.managed_policy_arn
+ }
+ }
+}
diff --git a/modules/runner-config/outputs.tf b/modules/runner-config/outputs.tf
new file mode 100644
index 0000000000..93582279da
--- /dev/null
+++ b/modules/runner-config/outputs.tf
@@ -0,0 +1,43 @@
+output "runner" {
+ description = "Common runner resources. The role is null when an external runner role is used."
+ value = {
+ role = one(aws_iam_role.runner[*])
+ }
+}
+
+output "scale_up" {
+ description = "Scale-up control-plane resources. Null when webhook orchestration is not configured."
+ value = one(module.orchestration_webhook[*].scale_up)
+}
+
+output "scale_down" {
+ description = "Scale-down control-plane resources. Null when webhook orchestration is not configured."
+ value = one(module.orchestration_webhook[*].scale_down)
+}
+
+output "pool" {
+ description = "Scheduled pool resources. Null when no pool configuration is supplied."
+ value = one(module.orchestration_webhook[*].pool)
+}
+
+output "orchestration_provider" {
+ description = "Resources grouped under the selected runner orchestration provider."
+ value = {
+ webhook = local.orchestration_provider_enabled.webhook ? {
+ scale_up = one(module.orchestration_webhook[*].scale_up)
+ scale_down = one(module.orchestration_webhook[*].scale_down)
+ pool = one(module.orchestration_webhook[*].pool)
+ job_retry = one(module.orchestration_webhook[*].job_retry)
+ } : null
+ }
+}
+
+output "provider" {
+ description = "Provider-specific resources grouped under the selected provider namespace and type."
+ value = {
+ aws = {
+ ec2 = local.provider_key == "aws_ec2" ? local.provider_contract.resources : null
+ microvm = local.provider_key == "aws_microvm" ? local.provider_contract.resources : null
+ }
+ }
+}
diff --git a/modules/runner-config/runner-role.tf b/modules/runner-config/runner-role.tf
new file mode 100644
index 0000000000..6baa1e4206
--- /dev/null
+++ b/modules/runner-config/runner-role.tf
@@ -0,0 +1,48 @@
+locals {
+ # Role ownership belongs to the common runner configuration. The selected trust-policy
+ # submodule supplies the assume-role document, while the full compute provider
+ # supplies permissions after the role has been resolved.
+ create_runner_role = var.runner.iam.role == null
+
+ runner_role = {
+ arn = local.create_runner_role ? one(aws_iam_role.runner[*].arn) : var.runner.iam.role.arn
+ name = local.create_runner_role ? one(aws_iam_role.runner[*].name) : basename(var.runner.iam.role.arn)
+ managed = local.create_runner_role
+ }
+
+ common_runner_managed_policy_arns = merge(
+ {
+ for policy_name, policy_arn in var.runner.iam.managed_policy_arns :
+ "user-${policy_name}" => policy_arn
+ },
+ var.observability.tracing.mode != null ? {
+ xray = "arn:${var.aws_partition}:iam::aws:policy/AWSXRayDaemonWriteAccess"
+ } : {},
+ )
+
+ provider_runner_policies = local.provider_contract.policies.runner
+}
+
+resource "aws_iam_role" "runner" {
+ count = local.create_runner_role ? 1 : 0
+ name = "${substr("${var.prefix}-runner", 0, 54)}-${substr(md5("${var.prefix}-runner"), 0, 8)}"
+ assume_role_policy = local.provider_assume_role_policy
+ path = local.runner_role_path
+ permissions_boundary = var.runner.iam.permissions_boundary
+ tags = local.runner_tags
+}
+
+resource "aws_iam_role_policy" "runner_provider" {
+ for_each = local.create_runner_role ? local.provider_runner_policies.inline_policies : {}
+
+ name = each.value.name
+ role = aws_iam_role.runner[0].name
+ policy = each.value.policy_json
+}
+
+resource "aws_iam_role_policy_attachment" "runner" {
+ for_each = local.create_runner_role ? local.provider_runner_policies.managed_policy_arns : {}
+
+ role = aws_iam_role.runner[0].name
+ policy_arn = each.value
+}
diff --git a/modules/runner-config/runner-ssm-parameters.tf b/modules/runner-config/runner-ssm-parameters.tf
new file mode 100644
index 0000000000..1d97c908a8
--- /dev/null
+++ b/modules/runner-config/runner-ssm-parameters.tf
@@ -0,0 +1,28 @@
+# Shared runner configuration stored in SSM Parameter Store.
+resource "aws_ssm_parameter" "runner_agent_mode" {
+ name = "${var.ssm.paths.root}/${var.ssm.paths.config}/agent_mode"
+ type = "String"
+ value = local.orchestration_provider_runner_lifecycle.ephemeral ? "ephemeral" : "persistent"
+ tags = local.ssm_parameter_tags
+}
+
+resource "aws_ssm_parameter" "disable_default_labels" {
+ name = "${var.ssm.paths.root}/${var.ssm.paths.config}/disable_default_labels"
+ type = "String"
+ value = var.runner.disable_default_labels
+ tags = local.ssm_parameter_tags
+}
+
+resource "aws_ssm_parameter" "jit_config_enabled" {
+ name = "${var.ssm.paths.root}/${var.ssm.paths.config}/enable_jit_config"
+ type = "String"
+ value = local.orchestration_provider_runner_lifecycle.jit_config_enabled
+ tags = local.ssm_parameter_tags
+}
+
+resource "aws_ssm_parameter" "token_path" {
+ name = "${var.ssm.paths.root}/${var.ssm.paths.config}/token_path"
+ type = "String"
+ value = "${var.ssm.paths.root}/${var.ssm.paths.tokens}"
+ tags = local.ssm_parameter_tags
+}
diff --git a/modules/runner-config/ssm-housekeeper.tf b/modules/runner-config/ssm-housekeeper.tf
new file mode 100644
index 0000000000..5bb31bb7b5
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper.tf
@@ -0,0 +1,57 @@
+locals {
+ ssm_housekeeper_token_path = coalesce(var.ssm.housekeeper.config.tokenPath, local.token_path)
+ ssm_housekeeper_parameter_path_arn = (
+ "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${local.ssm_housekeeper_token_path}*"
+ )
+}
+
+module "ssm_housekeeper" {
+ source = "./ssm-housekeeper"
+
+ config = {
+ prefix = var.prefix
+ aws_partition = var.aws_partition
+ schedule = {
+ expression = var.ssm.housekeeper.schedule_expression
+ state = var.ssm.housekeeper.state
+ }
+ cleanup = {
+ token_path = local.ssm_housekeeper_token_path
+ parameter_path_arn = local.ssm_housekeeper_parameter_path_arn
+ minimum_days_old = var.ssm.housekeeper.config.minimumDaysOld
+ dry_run = var.ssm.housekeeper.config.dryRun
+ }
+ lambda = {
+ # The housekeeper resolves only its component-owned selector and never
+ # inherits the selected orchestration provider's runner-control artifact.
+ artifact = local.ssm_housekeeper_artifact
+ runtime = var.lambda.runtime
+ architecture = var.lambda.architecture
+ memory_size = var.ssm.housekeeper.lambda.memory_size
+ timeout = var.ssm.housekeeper.lambda.timeout
+ vpc = {
+ subnet_ids = var.lambda.subnet_ids
+ security_group_ids = var.lambda.security_group_ids
+ }
+ role = {
+ path = local.lambda_role_path
+ permissions_boundary = var.lambda.role.permissions_boundary
+ principals = var.lambda.principals
+ }
+ }
+ observability = {
+ logs = {
+ level = var.observability.logs.level
+ retention_in_days = var.observability.logs.retention_in_days
+ kms_key_id = var.observability.logs.kms_key_id
+ class = var.observability.logs.class
+ }
+ tracing = var.observability.tracing
+ }
+ tags = {
+ resources = local.ssm_housekeeper_tags
+ lambda = local.ssm_housekeeper_lambda_tags
+ log_group = local.ssm_housekeeper_log_tags
+ }
+ }
+}
diff --git a/modules/runner-config/ssm-housekeeper/README.md b/modules/runner-config/ssm-housekeeper/README.md
new file mode 100644
index 0000000000..5f5d1ad166
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/README.md
@@ -0,0 +1,57 @@
+# SSM housekeeper module
+
+> This module is treated as an internal module; breaking changes do not trigger a major release bump.
+
+This provider-neutral child module owns the Lambda function, EventBridge schedule, IAM policies, and CloudWatch log group used to remove expired runner registration parameters from Parameter Store.
+
+The module is an implementation detail of the experimental runner configuration. It is composed by `runner-config` and is not intended to be called directly.
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.3.0 |
+| [aws](#requirement\_aws) | >= 6.33 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.33 |
+
+## Modules
+
+No modules.
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource |
+| [aws_cloudwatch_event_target.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource |
+| [aws_cloudwatch_log_group.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource |
+| [aws_iam_role.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
+| [aws_iam_role_policy.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_iam_role_policy.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_iam_role_policy.ssm_housekeeper_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource |
+| [aws_lambda_function.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource |
+| [aws_lambda_permission.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource |
+| [aws_iam_policy_document.lambda_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.lambda_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.ssm_housekeeper_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [config](#input\_config) | Provider-neutral SSM housekeeper configuration assembled by runner-config.object({
prefix = string
aws_partition = string
schedule = object({
expression = string
state = string
})
cleanup = object({
token_path = string
parameter_path_arn = string
minimum_days_old = number
dry_run = bool
})
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
}) | n/a | yes |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [housekeeper](#output\_housekeeper) | SSM housekeeper Lambda resources. |
+
diff --git a/modules/runner-config/ssm-housekeeper/iam-policies.tf b/modules/runner-config/ssm-housekeeper/iam-policies.tf
new file mode 100644
index 0000000000..8d3bab2865
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/iam-policies.tf
@@ -0,0 +1,58 @@
+data "aws_iam_policy_document" "lambda_assume_role" {
+ statement {
+ actions = ["sts:AssumeRole"]
+
+ principals {
+ type = "Service"
+ identifiers = ["lambda.amazonaws.com"]
+ }
+
+ dynamic "principals" {
+ for_each = var.config.lambda.role.principals
+
+ content {
+ type = principals.value.type
+ identifiers = principals.value.identifiers
+ }
+ }
+ }
+}
+
+data "aws_iam_policy_document" "lambda_xray" {
+ count = var.config.observability.tracing.mode != null ? 1 : 0
+
+ # AWS X-Ray trace APIs do not support resource-level permissions.
+ statement {
+ sid = "AllowXRay"
+ effect = "Allow"
+ actions = [
+ "xray:BatchGetTraces",
+ "xray:GetTraceSummaries",
+ "xray:PutTelemetryRecords",
+ "xray:PutTraceSegments",
+ ]
+ resources = ["*"]
+ }
+}
+
+data "aws_iam_policy_document" "ssm_housekeeper" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "ssm:DeleteParameter",
+ "ssm:GetParametersByPath",
+ ]
+ resources = [var.config.cleanup.parameter_path_arn]
+ }
+}
+
+data "aws_iam_policy_document" "ssm_housekeeper_logging" {
+ statement {
+ effect = "Allow"
+ actions = [
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ ]
+ resources = ["${aws_cloudwatch_log_group.ssm_housekeeper.arn}*"]
+ }
+}
diff --git a/modules/runner-config/ssm-housekeeper/outputs.tf b/modules/runner-config/ssm-housekeeper/outputs.tf
new file mode 100644
index 0000000000..064f5a1ab1
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/outputs.tf
@@ -0,0 +1,8 @@
+output "housekeeper" {
+ description = "SSM housekeeper Lambda resources."
+ value = {
+ lambda = aws_lambda_function.ssm_housekeeper
+ log_group = aws_cloudwatch_log_group.ssm_housekeeper
+ role = aws_iam_role.ssm_housekeeper
+ }
+}
diff --git a/modules/runner-config/ssm-housekeeper/ssm-housekeeper.tf b/modules/runner-config/ssm-housekeeper/ssm-housekeeper.tf
new file mode 100644
index 0000000000..bcafed201a
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/ssm-housekeeper.tf
@@ -0,0 +1,119 @@
+locals {
+ vpc_enabled = (
+ length(var.config.lambda.vpc.subnet_ids) > 0 &&
+ length(var.config.lambda.vpc.security_group_ids) > 0
+ )
+
+ cleanup_config = {
+ tokenPath = var.config.cleanup.token_path
+ minimumDaysOld = var.config.cleanup.minimum_days_old
+ dryRun = var.config.cleanup.dry_run
+ }
+}
+
+resource "aws_lambda_function" "ssm_housekeeper" {
+ s3_bucket = var.config.lambda.artifact.s3.bucket
+ s3_key = var.config.lambda.artifact.s3.key
+ s3_object_version = var.config.lambda.artifact.s3.object_version
+ filename = var.config.lambda.artifact.s3.bucket == null ? var.config.lambda.artifact.zip : null
+ source_code_hash = var.config.lambda.artifact.s3.bucket == null ? filebase64sha256(var.config.lambda.artifact.zip) : null
+ function_name = "${var.config.prefix}-ssm-housekeeper"
+ role = aws_iam_role.ssm_housekeeper.arn
+ handler = "index.ssmHousekeeper"
+ runtime = var.config.lambda.runtime
+ timeout = var.config.lambda.timeout
+ tags = var.config.tags.lambda
+ memory_size = var.config.lambda.memory_size
+ architectures = [var.config.lambda.architecture]
+
+ environment {
+ variables = {
+ ENVIRONMENT = var.config.prefix
+ LOG_LEVEL = upper(var.config.observability.logs.level)
+ SSM_CLEANUP_CONFIG = jsonencode(local.cleanup_config)
+ POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-ssm-housekeeper"
+ POWERTOOLS_TRACE_ENABLED = var.config.observability.tracing.mode != null
+ POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests
+ POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error
+ }
+ }
+
+ dynamic "vpc_config" {
+ for_each = local.vpc_enabled ? [true] : []
+
+ content {
+ security_group_ids = var.config.lambda.vpc.security_group_ids
+ subnet_ids = var.config.lambda.vpc.subnet_ids
+ }
+ }
+
+ dynamic "tracing_config" {
+ for_each = var.config.observability.tracing.mode != null ? [true] : []
+
+ content {
+ mode = var.config.observability.tracing.mode
+ }
+ }
+}
+
+resource "aws_cloudwatch_log_group" "ssm_housekeeper" {
+ name = "/aws/lambda/${aws_lambda_function.ssm_housekeeper.function_name}"
+ retention_in_days = var.config.observability.logs.retention_in_days
+ kms_key_id = var.config.observability.logs.kms_key_id
+ log_group_class = var.config.observability.logs.class
+ tags = var.config.tags.log_group
+}
+
+resource "aws_cloudwatch_event_rule" "ssm_housekeeper" {
+ name = "${var.config.prefix}-ssm-housekeeper"
+ schedule_expression = var.config.schedule.expression
+ state = var.config.schedule.state
+ tags = var.config.tags.resources
+}
+
+resource "aws_cloudwatch_event_target" "ssm_housekeeper" {
+ rule = aws_cloudwatch_event_rule.ssm_housekeeper.name
+ arn = aws_lambda_function.ssm_housekeeper.arn
+}
+
+resource "aws_lambda_permission" "ssm_housekeeper" {
+ statement_id = "AllowExecutionFromCloudWatch"
+ action = "lambda:InvokeFunction"
+ function_name = aws_lambda_function.ssm_housekeeper.function_name
+ principal = "events.amazonaws.com"
+ source_arn = aws_cloudwatch_event_rule.ssm_housekeeper.arn
+}
+
+resource "aws_iam_role" "ssm_housekeeper" {
+ name = "${substr("${var.config.prefix}-ssm-hk-lambda", 0, 54)}-${substr(md5("${var.config.prefix}-ssm-hk-lambda"), 0, 8)}"
+ description = "Lambda role for SSM Housekeeper (${var.config.prefix})"
+ assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
+ path = var.config.lambda.role.path
+ permissions_boundary = var.config.lambda.role.permissions_boundary
+ tags = var.config.tags.resources
+}
+
+resource "aws_iam_role_policy" "ssm_housekeeper" {
+ name = "ssm-policy"
+ role = aws_iam_role.ssm_housekeeper.name
+ policy = data.aws_iam_policy_document.ssm_housekeeper.json
+}
+
+resource "aws_iam_role_policy" "ssm_housekeeper_logging" {
+ name = "logging-policy"
+ role = aws_iam_role.ssm_housekeeper.name
+ policy = data.aws_iam_policy_document.ssm_housekeeper_logging.json
+}
+
+resource "aws_iam_role_policy_attachment" "ssm_housekeeper_vpc_execution_role" {
+ count = local.vpc_enabled ? 1 : 0
+ role = aws_iam_role.ssm_housekeeper.name
+ policy_arn = "arn:${var.config.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
+}
+
+resource "aws_iam_role_policy" "ssm_housekeeper_xray" {
+ count = var.config.observability.tracing.mode != null ? 1 : 0
+ name = "xray-policy"
+ policy = data.aws_iam_policy_document.lambda_xray[0].json
+ role = aws_iam_role.ssm_housekeeper.name
+}
diff --git a/modules/runner-config/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl b/modules/runner-config/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl
new file mode 100644
index 0000000000..bac30c6752
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/tests/ssm-housekeeper.tftest.hcl
@@ -0,0 +1,263 @@
+mock_provider "aws" {
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ }
+ }
+
+ mock_resource "aws_iam_role" {
+ defaults = {
+ arn = "arn:aws:iam::123456789012:role/ssm-housekeeper-test"
+ }
+ }
+
+ mock_resource "aws_lambda_function" {
+ defaults = {
+ arn = "arn:aws:lambda:eu-west-1:123456789012:function:ssm-housekeeper-test"
+ }
+ }
+
+ mock_resource "aws_cloudwatch_event_rule" {
+ defaults = {
+ arn = "arn:aws:events:eu-west-1:123456789012:rule/ssm-housekeeper-test"
+ }
+ }
+
+ mock_resource "aws_cloudwatch_log_group" {
+ defaults = {
+ arn = "arn:aws:logs:eu-west-1:123456789012:log-group:/aws/lambda/ssm-housekeeper-test"
+ }
+ }
+}
+
+variables {
+ config = {
+ prefix = "ssm-housekeeper-test"
+ aws_partition = "aws-us-gov"
+ schedule = {
+ expression = "rate(6 hours)"
+ state = "DISABLED"
+ }
+ cleanup = {
+ token_path = "/custom/runner/tokens"
+ parameter_path_arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/custom/runner/tokens*"
+ minimum_days_old = 7
+ dry_run = true
+ }
+ lambda = {
+ artifact = {
+ zip = "unused-with-s3.zip"
+ s3 = {
+ bucket = "lambda-artifacts"
+ key = "control-plane/runners.zip"
+ object_version = "version-1"
+ }
+ }
+ runtime = "nodejs24.x"
+ architecture = "arm64"
+ memory_size = 384
+ timeout = 45
+ vpc = {
+ subnet_ids = []
+ security_group_ids = []
+ }
+ role = {
+ path = "/runner-config/"
+ permissions_boundary = null
+ principals = [{
+ type = "AWS"
+ identifiers = ["arn:aws-us-gov:iam::123456789012:role/local-testing"]
+ }]
+ }
+ }
+ observability = {
+ logs = {
+ level = "debug"
+ retention_in_days = 30
+ kms_key_id = null
+ class = "STANDARD"
+ }
+ tracing = {
+ mode = null
+ capture_http_requests = false
+ capture_error = false
+ }
+ }
+ tags = {
+ resources = {
+ Scope = "housekeeper"
+ }
+ lambda = {
+ Scope = "housekeeper"
+ Resource = "lambda"
+ }
+ log_group = {
+ Scope = "housekeeper"
+ Resource = "logs"
+ }
+ }
+ }
+}
+
+run "configures_schedule_cleanup_and_outputs" {
+ command = plan
+
+ assert {
+ condition = (
+ length(data.aws_iam_policy_document.lambda_assume_role.statement[0].principals) == 2 &&
+ contains(data.aws_iam_policy_document.lambda_assume_role.statement[0].principals[*].type, "AWS")
+ )
+ error_message = "The housekeeper Lambda trust policy must include configured additional principals."
+ }
+
+ assert {
+ condition = (
+ aws_cloudwatch_event_rule.ssm_housekeeper.schedule_expression == "rate(6 hours)" &&
+ aws_cloudwatch_event_rule.ssm_housekeeper.state == "DISABLED"
+ )
+ error_message = "The housekeeper EventBridge rule must use the configured schedule and state."
+ }
+
+ assert {
+ condition = (
+ jsondecode(aws_lambda_function.ssm_housekeeper.environment[0].variables["SSM_CLEANUP_CONFIG"]).tokenPath == "/custom/runner/tokens" &&
+ jsondecode(aws_lambda_function.ssm_housekeeper.environment[0].variables["SSM_CLEANUP_CONFIG"]).minimumDaysOld == 7 &&
+ jsondecode(aws_lambda_function.ssm_housekeeper.environment[0].variables["SSM_CLEANUP_CONFIG"]).dryRun
+ )
+ error_message = "The Lambda cleanup configuration must preserve the configured path override, age, and dry-run setting."
+ }
+
+ assert {
+ condition = contains(
+ data.aws_iam_policy_document.ssm_housekeeper.statement[0].resources,
+ "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/custom/runner/tokens*",
+ )
+ error_message = "The housekeeper IAM policy must authorize the same overridden Parameter Store path supplied to the Lambda."
+ }
+
+ assert {
+ condition = toset(keys(output.housekeeper)) == toset(["lambda", "log_group", "role"])
+ error_message = "The module must expose Lambda, log-group, and role resources through one nested housekeeper output."
+ }
+
+ assert {
+ condition = (
+ output.housekeeper.lambda.tags == tomap({
+ Scope = "housekeeper"
+ Resource = "lambda"
+ }) &&
+ output.housekeeper.log_group.tags == tomap({
+ Scope = "housekeeper"
+ Resource = "logs"
+ }) &&
+ output.housekeeper.role.tags == tomap({
+ Scope = "housekeeper"
+ })
+ )
+ error_message = "Each nested output resource must retain its resolved component tags."
+ }
+
+ assert {
+ condition = (
+ length(aws_lambda_function.ssm_housekeeper.vpc_config) == 0 &&
+ length(aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role) == 0 &&
+ length(aws_lambda_function.ssm_housekeeper.tracing_config) == 0 &&
+ length(aws_iam_role_policy.ssm_housekeeper_xray) == 0
+ )
+ error_message = "Empty VPC configuration and disabled tracing must not create their optional Lambda or IAM configuration."
+ }
+}
+
+run "enables_vpc_and_xray_together" {
+ command = plan
+
+ variables {
+ config = {
+ prefix = "ssm-housekeeper-vpc-test"
+ aws_partition = "aws-us-gov"
+ schedule = {
+ expression = "rate(1 day)"
+ state = "ENABLED"
+ }
+ cleanup = {
+ token_path = "/github-runner/tokens"
+ parameter_path_arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/tokens*"
+ minimum_days_old = 1
+ dry_run = false
+ }
+ lambda = {
+ artifact = {
+ zip = "unused-with-s3.zip"
+ s3 = {
+ bucket = "lambda-artifacts"
+ key = "control-plane/runners.zip"
+ }
+ }
+ runtime = "nodejs24.x"
+ architecture = "arm64"
+ memory_size = 512
+ timeout = 60
+ vpc = {
+ subnet_ids = ["subnet-12345678"]
+ security_group_ids = ["sg-12345678"]
+ }
+ role = {
+ path = "/runner-config/"
+ permissions_boundary = null
+ }
+ }
+ observability = {
+ logs = {
+ level = "info"
+ retention_in_days = 14
+ kms_key_id = null
+ class = "STANDARD"
+ }
+ tracing = {
+ mode = "Active"
+ capture_http_requests = true
+ capture_error = true
+ }
+ }
+ tags = {
+ resources = {}
+ lambda = {}
+ log_group = {}
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ length(aws_lambda_function.ssm_housekeeper.vpc_config) == 1 &&
+ aws_lambda_function.ssm_housekeeper.vpc_config[0].subnet_ids == toset(["subnet-12345678"]) &&
+ aws_lambda_function.ssm_housekeeper.vpc_config[0].security_group_ids == toset(["sg-12345678"]) &&
+ length(aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role) == 1 &&
+ aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role[0].policy_arn == "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
+ )
+ error_message = "A complete VPC configuration must configure the Lambda and attach the partition-aware VPC execution policy."
+ }
+
+ assert {
+ condition = (
+ length(aws_lambda_function.ssm_housekeeper.tracing_config) == 1 &&
+ aws_lambda_function.ssm_housekeeper.tracing_config[0].mode == "Active" &&
+ length(aws_iam_role_policy.ssm_housekeeper_xray) == 1 &&
+ aws_lambda_function.ssm_housekeeper.environment[0].variables["POWERTOOLS_TRACE_ENABLED"] == "true" &&
+ aws_lambda_function.ssm_housekeeper.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS"] == "true" &&
+ aws_lambda_function.ssm_housekeeper.environment[0].variables["POWERTOOLS_TRACER_CAPTURE_ERROR"] == "true"
+ )
+ error_message = "Active tracing must configure Lambda tracing, X-Ray IAM permissions, and tracing-helper environment variables."
+ }
+
+ assert {
+ condition = (
+ data.aws_iam_policy_document.lambda_xray[0].statement[0].resources == toset(["*"])
+ && alltrue([
+ for action in data.aws_iam_policy_document.lambda_xray[0].statement[0].actions :
+ startswith(action, "xray:")
+ ])
+ )
+ error_message = "The housekeeper wildcard resource must be limited to X-Ray APIs, which do not support resource-level IAM permissions."
+ }
+}
diff --git a/modules/runner-config/ssm-housekeeper/variables.tf b/modules/runner-config/ssm-housekeeper/variables.tf
new file mode 100644
index 0000000000..64848fc33c
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/variables.tf
@@ -0,0 +1,93 @@
+variable "config" {
+ description = <<-EOT
+ Provider-neutral SSM housekeeper configuration assembled by runner-config.
+
+ - `prefix`: Prefix used to name the housekeeper resources.
+ - `aws_partition`: AWS partition used to construct IAM policy ARNs.
+ - `schedule.expression`: EventBridge schedule expression that invokes the housekeeper.
+ - `schedule.state`: State of the EventBridge rule.
+ - `cleanup.token_path`: Parameter Store token path supplied to the Lambda.
+ - `cleanup.parameter_path_arn`: IAM resource ARN matching `cleanup.token_path`.
+ - `cleanup.minimum_days_old`: Minimum parameter age before deletion.
+ - `cleanup.dry_run`: Reports eligible parameters without deleting them when true.
+ - `lambda.artifact.zip`: Resolved local control-plane archive.
+ - `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
+ - `lambda.artifact.s3.key`: Object key of the Lambda archive.
+ - `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
+ - `lambda.runtime`: Runtime used by the housekeeper Lambda.
+ - `lambda.architecture`: Instruction-set architecture used by the housekeeper Lambda.
+ - `lambda.memory_size`: Memory allocated to the housekeeper Lambda.
+ - `lambda.timeout`: Housekeeper Lambda timeout in seconds.
+ - `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
+ - `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
+ - `lambda.role.path`: IAM path used for the housekeeper Lambda role.
+ - `lambda.role.permissions_boundary`: Optional permissions boundary for the housekeeper role.
+ - `lambda.role.principals`: Additional principals allowed to assume the housekeeper Lambda role.
+ - `observability.logs`: Logging level, retention, encryption, and log-class configuration.
+ - `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
+ - `tags.resources`: Tags for the housekeeper role and EventBridge rule.
+ - `tags.lambda`: Tags for the housekeeper Lambda function.
+ - `tags.log_group`: Tags for the housekeeper log group.
+ EOT
+
+ type = object({
+ prefix = string
+ aws_partition = string
+ schedule = object({
+ expression = string
+ state = string
+ })
+ cleanup = object({
+ token_path = string
+ parameter_path_arn = string
+ minimum_days_old = number
+ dry_run = bool
+ })
+ lambda = object({
+ artifact = object({
+ zip = string
+ s3 = object({
+ bucket = optional(string, null)
+ key = optional(string, null)
+ object_version = optional(string, null)
+ })
+ })
+ runtime = string
+ architecture = string
+ memory_size = number
+ timeout = number
+ vpc = object({
+ subnet_ids = list(string)
+ security_group_ids = list(string)
+ })
+ role = object({
+ path = string
+ permissions_boundary = optional(string, null)
+ principals = optional(list(object({
+ type = string
+ identifiers = list(string)
+ })), [])
+ })
+ })
+ observability = object({
+ logs = object({
+ level = string
+ retention_in_days = number
+ kms_key_id = optional(string, null)
+ class = string
+ })
+ tracing = object({
+ mode = optional(string, null)
+ capture_http_requests = bool
+ capture_error = bool
+ })
+ })
+ tags = object({
+ resources = map(string)
+ lambda = map(string)
+ log_group = map(string)
+ })
+ })
+
+ nullable = false
+}
diff --git a/modules/runner-config/ssm-housekeeper/versions.tf b/modules/runner-config/ssm-housekeeper/versions.tf
new file mode 100644
index 0000000000..da9769f550
--- /dev/null
+++ b/modules/runner-config/ssm-housekeeper/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.3.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.33"
+ }
+ }
+}
diff --git a/modules/runner-config/tests/README.md b/modules/runner-config/tests/README.md
new file mode 100644
index 0000000000..fa55dfecd9
--- /dev/null
+++ b/modules/runner-config/tests/README.md
@@ -0,0 +1,72 @@
+# Terraform Tests
+
+This directory contains [Terraform test files](https://developer.hashicorp.com/terraform/language/tests) (`.tftest.hcl`) for the runners module.
+
+## Why `terraform test` instead of `terraform validate`?
+
+`terraform validate` only checks syntax and basic type correctness of the configuration. It **cannot** detect:
+
+- Conditional expressions with inconsistent result types (e.g., one branch returns an object with 1 attribute, the other returns 16)
+- Runtime type mismatches that only surface during `plan`
+- Invalid cross-module references that depend on resource attribute shapes
+
+`terraform test` with `mock_provider` runs a full plan without needing real cloud credentials, catching these classes of bugs in CI.
+
+## Requirements
+
+- Terraform >= 1.7 (for `mock_provider` and `mock_data` support)
+- No AWS credentials required — all providers are mocked
+
+## Running locally
+
+```bash
+cd modules/runners
+terraform test -test-directory=tests
+```
+
+Expected output:
+
+```
+tests/pool.tftest.hcl... in progress
+ run "plan_with_pool_enabled"... pass
+tests/pool.tftest.hcl... pass
+
+Success! 1 passed, 0 failed.
+```
+
+## Writing new tests
+
+1. Create a `.tftest.hcl` file in this directory
+2. Use `mock_provider "aws" {}` to avoid needing credentials
+3. Use `mock_data` blocks to provide realistic values for data sources that perform validation (e.g., `aws_iam_policy_document` validates JSON)
+4. Set all required variables in a `variables {}` block
+5. Use `run` blocks with `command = plan` and `assert` conditions
+
+### Example template
+
+```hcl
+mock_provider "aws" {
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}"
+ }
+ }
+}
+
+variables {
+ # ... required variables ...
+}
+
+run "descriptive_test_name" {
+ command = plan
+
+ assert {
+ condition =