diff --git a/.github/workflows/packer-build.yml b/.github/workflows/packer-build.yml index c72f230cee..977201f46e 100644 --- a/.github/workflows/packer-build.yml +++ b/.github/workflows/packer-build.yml @@ -28,7 +28,7 @@ jobs: image: index.docker.io/hashicorp/packer@sha256:12c441b8a3994e7df9f0e2692d9298f14c387e70bcc06139420977dbf80a137b # 1.11.2 strategy: matrix: - image: ["linux-al2023", "windows-core-2019", "windows-core-2022", "ubuntu-focal", "ubuntu-jammy", "ubuntu-jammy-arm64"] + image: ["linux-al2023", "windows-core-2019", "windows-core-2022", "ubuntu-focal", "ubuntu-jammy", "ubuntu-jammy-arm64", "microvm-ubuntu"] defaults: run: working-directory: images/${{ matrix.image }} diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 211f2bf37c..df59ae4baa 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -160,7 +160,8 @@ jobs: "multi-runner", "multi-runner-v2", "external-managed-ssm-secrets", - "microvm-foundation" + "microvm-foundation", + "microvm" ] defaults: run: diff --git a/docs/examples/microvm.md b/docs/examples/microvm.md new file mode 100644 index 0000000000..4014781114 --- /dev/null +++ b/docs/examples/microvm.md @@ -0,0 +1,3 @@ +# Lambda MicroVM + +--8<-- "examples/microvm/README.md" diff --git a/examples/microvm-foundation/README.md b/examples/microvm-foundation/README.md index aa54e94fa0..841552abd5 100644 --- a/examples/microvm-foundation/README.md +++ b/examples/microvm-foundation/README.md @@ -14,7 +14,7 @@ terraform output ``` Apply this foundation before building an image with the direct Packer commands -documented in `../../images/microvm/README.md`. Use the outputs as the build inputs: +documented in `../../images/microvm-ubuntu/README.md`. Use the outputs as the build inputs: - `artifact_bucket_name` -> `MICROVM_ARTIFACT_BUCKET` - `build_role_arn` -> `MICROVM_BUILD_ROLE_ARN` diff --git a/examples/microvm/.terraform.lock.hcl b/examples/microvm/.terraform.lock.hcl new file mode 100644 index 0000000000..7a131aab93 --- /dev/null +++ b/examples/microvm/.terraform.lock.hcl @@ -0,0 +1,68 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" + hashes = [ + "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=", + "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611", + "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d", + "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a", + "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1", + "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d", + "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c", + "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb", + "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9", + "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd", + "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb", + "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925", + "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04", + "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54", + "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.3.1" + constraints = "~> 3.0, ~> 3.2" + hashes = [ + "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=", + "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44", + "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451", + "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05", + "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3", + "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7", + "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1", + "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be", + "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891", + "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5", + "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610", + "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/examples/microvm/README.md b/examples/microvm/README.md new file mode 100644 index 0000000000..514c790110 --- /dev/null +++ b/examples/microvm/README.md @@ -0,0 +1,102 @@ +# Lambda MicroVM runner example + +This example creates the VPC and GitHub Actions runner control plane for one +Linux ARM64 Lambda MicroVM lane. The lane uses ephemeral runners and +just-in-time configuration, which are required by the MicroVM provider. + +The regional MicroVM foundation is provisioned separately by the +[`microvm-foundation`](../microvm-foundation) example. Apply that example +first and provide its artifact bucket, build role, and egress Network Connector +outputs to the image build script. The image ARN produced by that build is then +supplied to this example. + +The GitHub App credentials must already exist in SSM Parameter Store. The +example outputs the webhook endpoint; configure that endpoint on the GitHub +App with the same secret stored in the referenced SSM parameter. + +## Usage + +Build or download the Lambda archives into an S3 bucket, then create a +`terraform.tfvars` file. The parameter references below are examples only: + +```hcl +aws_region = "eu-west-1" +lambda_artifact_bucket = "my-runner-lambda-artifacts" +microvm_image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:github-runner-arm64" +egress_network_connector_arn = "arn:aws:lambda:eu-west-1:123456789012:network-connector:example" + +github_app = { + key_base64_ssm = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-key" + name = "/github-runner/app-key" + } + id_ssm = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + name = "/github-runner/app-id" + } + webhook_secret_ssm = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/webhook-secret" + name = "/github-runner/webhook-secret" + } +} +``` + +Run Terraform from this directory: + +```bash +terraform init +terraform apply +terraform output -raw webhook_endpoint +``` + +The MicroVM image must be built for Linux ARM64 and should use a versioned image +ARN in production. Network connector egress remains bounded by the VPC route +tables and network ACLs configured by the helper module. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [base](#module\_base) | ../base | n/a | +| [runners](#module\_runners) | ../../modules/multi-runner | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_region](#input\_aws\_region) | AWS Region where the runner control plane and MicroVM resources are deployed. | `string` | `"eu-west-1"` | no | +| [egress\_network\_connector\_arn](#input\_egress\_network\_connector\_arn) | Regional Lambda Network Connector ARN used by MicroVMs and the image build. | `string` | n/a | yes | +| [environment](#input\_environment) | Name prefix for the example resources. | `string` | `null` | no | +| [github\_app](#input\_github\_app) | Pre-created SSM parameter references for the GitHub App credentials. |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | +| [ingress\_network\_connector\_arns](#input\_ingress\_network\_connector\_arns) | Optional regional Lambda Network Connector ARNs exposed to MicroVMs. | `list(string)` | `[]` | no | +| [lambda\_artifact\_bucket](#input\_lambda\_artifact\_bucket) | S3 bucket containing the runner-control Lambda artifacts. | `string` | n/a | yes | +| [microvm\_image\_arn](#input\_microvm\_image\_arn) | Lambda MicroVM image ARN produced by the MicroVM image build. | `string` | n/a | yes | +| [microvm\_image\_version](#input\_microvm\_image\_version) | Optional immutable version of the Lambda MicroVM image. | `string` | `null` | no | +| [organization\_runners](#input\_organization\_runners) | Register the MicroVM runners at organization scope when true. | `bool` | `false` | no | +| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for the runners Lambda archive. | `string` | `"runners.zip"` | no | +| [runners\_maximum\_count](#input\_runners\_maximum\_count) | Maximum number of concurrent MicroVM runners. | `number` | `10` | no | +| [webhook\_lambda\_s3\_key](#input\_webhook\_lambda\_s3\_key) | S3 key for the webhook Lambda archive. | `string` | `"webhook.zip"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [microvm\_image\_arn](#output\_microvm\_image\_arn) | The MicroVM image ARN consumed by this runner configuration. | +| [webhook\_endpoint](#output\_webhook\_endpoint) | Webhook endpoint to configure on the GitHub App. | + diff --git a/examples/microvm/main.tf b/examples/microvm/main.tf new file mode 100644 index 0000000000..e3b2aaa6b4 --- /dev/null +++ b/examples/microvm/main.tf @@ -0,0 +1,110 @@ +locals { + environment = coalesce(var.environment, "microvm") + aws_region = var.aws_region +} + +module "base" { + source = "../base" + + prefix = local.environment + aws_region = local.aws_region +} + +module "runners" { + source = "../../modules/multi-runner" + + aws_region = local.aws_region + vpc_id = module.base.vpc.vpc_id + subnet_ids = module.base.vpc.private_subnets + prefix = local.environment + + # Required for backwards-compatible module input validation; the non-empty + # experimental map selects the MicroVM configuration below. + multi_runner_config = {} + + # Keep GitHub App credentials in pre-created SSM parameters. This example + # therefore does not place the private key or webhook secret in Terraform + # configuration or state. + github_app = var.github_app + + experimental_global_config_github = { + app = var.github_app + } + + experimental_global_config_lambda = { + artifact = { + s3 = { + bucket = var.lambda_artifact_bucket + } + } + } + + experimental_global_config_orchestration_provider = { + webhook = { + runner = { + ephemeral = true + jit_config_enabled = true + maximum_count = var.runners_maximum_count + boot_time_in_minutes = 5 + } + github = { + organization_runners = var.organization_runners + } + lambda = { + artifact = { + s3 = { + key = var.runners_lambda_s3_key + } + } + webhook = { + artifact = { + s3 = { + key = var.webhook_lambda_s3_key + } + } + } + } + } + } + + experimental_global_config_ssm = { + paths = { + root = "/github-action-runners/${local.environment}" + } + } + + experimental_global_config_compute_provider = { + aws = { + microvm = { + image_arn = var.microvm_image_arn + image_version = var.microvm_image_version + ingress_network_connectors = var.ingress_network_connector_arns + egress_network_connectors = [var.egress_network_connector_arn] + } + } + } + + experimental_multi_runner_config = { + microvm = { + runner = { + os = "linux" + architecture = "arm64" + name_prefix = "microvm-" + extra_labels = ["microvm"] + } + orchestration_provider = { + webhook = { + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "arm64", "microvm"]] + bidirectionalLabelMatch = true + } + } + } + compute_provider = { + aws = { + microvm = {} + } + } + } + } +} diff --git a/examples/microvm/outputs.tf b/examples/microvm/outputs.tf new file mode 100644 index 0000000000..87ad4c924c --- /dev/null +++ b/examples/microvm/outputs.tf @@ -0,0 +1,9 @@ +output "webhook_endpoint" { + description = "Webhook endpoint to configure on the GitHub App." + value = module.runners.webhook.endpoint +} + +output "microvm_image_arn" { + description = "The MicroVM image ARN consumed by this runner configuration." + value = var.microvm_image_arn +} diff --git a/examples/microvm/providers.tf b/examples/microvm/providers.tf new file mode 100644 index 0000000000..eca2fe96a7 --- /dev/null +++ b/examples/microvm/providers.tf @@ -0,0 +1,9 @@ +provider "aws" { + region = local.aws_region + + default_tags { + tags = { + Example = local.environment + } + } +} diff --git a/examples/microvm/variables.tf b/examples/microvm/variables.tf new file mode 100644 index 0000000000..7a6f1abd61 --- /dev/null +++ b/examples/microvm/variables.tf @@ -0,0 +1,95 @@ +variable "aws_region" { + description = "AWS Region where the runner control plane and MicroVM resources are deployed." + type = string + default = "eu-west-1" +} + +variable "environment" { + description = "Name prefix for the example resources." + type = string + default = null +} + +variable "github_app" { + description = "Pre-created SSM parameter references for the GitHub App credentials." + type = object({ + key_base64 = optional(string) + key_base64_ssm = optional(object({ + arn = string + name = string + })) + id = optional(string) + id_ssm = optional(object({ + arn = string + name = string + })) + webhook_secret = optional(string) + webhook_secret_ssm = optional(object({ + arn = string + name = string + })) + }) + + validation { + condition = ( + var.github_app.key_base64 == null && + var.github_app.id == null && + var.github_app.webhook_secret == null && + var.github_app.key_base64_ssm != null && + var.github_app.id_ssm != null && + var.github_app.webhook_secret_ssm != null + ) + error_message = "github_app must use pre-created SSM parameters for the key, app ID, and webhook secret." + } +} + +variable "lambda_artifact_bucket" { + description = "S3 bucket containing the runner-control Lambda artifacts." + type = string +} + +variable "runners_lambda_s3_key" { + description = "S3 key for the runners Lambda archive." + type = string + default = "runners.zip" +} + +variable "webhook_lambda_s3_key" { + description = "S3 key for the webhook Lambda archive." + type = string + default = "webhook.zip" +} + +variable "microvm_image_arn" { + description = "Lambda MicroVM image ARN produced by the MicroVM image build." + type = string +} + +variable "microvm_image_version" { + description = "Optional immutable version of the Lambda MicroVM image." + type = string + default = null +} + +variable "egress_network_connector_arn" { + description = "Regional Lambda Network Connector ARN used by MicroVMs and the image build." + type = string +} + +variable "ingress_network_connector_arns" { + description = "Optional regional Lambda Network Connector ARNs exposed to MicroVMs." + type = list(string) + default = [] +} + +variable "organization_runners" { + description = "Register the MicroVM runners at organization scope when true." + type = bool + default = false +} + +variable "runners_maximum_count" { + description = "Maximum number of concurrent MicroVM runners." + type = number + default = 10 +} diff --git a/examples/microvm/versions.tf b/examples/microvm/versions.tf new file mode 100644 index 0000000000..e4d4e1e015 --- /dev/null +++ b/examples/microvm/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } + required_version = ">= 1.3.0" +} diff --git a/images/README.md b/images/README.md index 689f3e2df5..c6722c2c2f 100644 --- a/images/README.md +++ b/images/README.md @@ -39,3 +39,16 @@ ami_owners = [""] enable_userdata = false ``` + +## Lambda MicroVM images + +The `microvm-ubuntu` directory contains the Packer inputs for the Lambda MicroVM +image workflow. Unlike the AMI examples above, Lambda owns the image build. +The Packer template, Dockerfile, lifecycle-hook ZIP contract, and image +entrypoint are under `microvm-ubuntu/`; the compiled hook server is supplied +separately as a build artifact. + +Apply [`examples/microvm-foundation`](../examples/microvm-foundation) first, +then follow the [`microvm-ubuntu` build instructions](microvm-ubuntu/README.md) and run +Packer with its outputs. Use the resulting image ARN in the +[`examples/microvm`](../examples/microvm) runner example. diff --git a/images/microvm-ubuntu/README.md b/images/microvm-ubuntu/README.md new file mode 100644 index 0000000000..84feeb24b4 --- /dev/null +++ b/images/microvm-ubuntu/README.md @@ -0,0 +1,46 @@ +# Lambda MicroVM image build + +This directory contains the complete Lambda MicroVM image build inputs adapted +from the companion base-image repository: the Packer template, pinned ARM64 +Dockerfile, compiled lifecycle-hook ZIP contract, and image entrypoint. + +Before building the image: + +1. Apply `examples/microvm-foundation` in the target AWS Region. +2. Install Packer and set the required AWS, S3, IAM, connector, and + lifecycle-hook variables. + +The image intentionally excludes the source repository's optional external +telemetry and Teleport services. It contains only the Actions runner, +CloudWatch Agent, and lifecycle-hook server; no credentials are stored in the +image source. + +The `github_agent.microvm.ubuntu.pkr.hcl` template packages a deterministic +artifact, resolves the Ubuntu ECR mirror to a digest, uploads the artifact to +the regional S3 bucket, and waits for the Lambda MicroVM image version to +become active. + +```bash +export AWS_REGION="" +export AWS_DATA_PATH="" +export MICROVM_ARTIFACT_BUCKET="" +export MICROVM_BUILD_ROLE_ARN="" +export MICROVM_EGRESS_NETWORK_CONNECTOR_ARN="" +export MICROVM_IMAGE_NAME="" +export MICROVM_LIFECYCLE_HOOK_ZIP="" +export MICROVM_LOG_GROUP="" +export MICROVM_MEMORY_MIB=8192 +export MICROVM_UBUNTU_IMAGE="" +export MICROVM_IDEMPOTENCY_NONCE="$(date -u +%Y%m%dT%H%M%SZ)" + +packer init . +packer fmt -check=true github_agent.microvm.ubuntu.pkr.hcl +packer validate -evaluate-datasources github_agent.microvm.ubuntu.pkr.hcl +packer build -color=false github_agent.microvm.ubuntu.pkr.hcl +``` + +The build role, artifact bucket, and network connector are created by the +foundation module. Keep the bucket private and versioned, use the module's +least-privilege policies, and do not put credentials in checked-in files. The +lifecycle-hook ZIP must contain the compiled `server.js` at its archive root; +any bundled dependencies must use safe relative paths. diff --git a/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl b/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl new file mode 100644 index 0000000000..f8d0638d94 --- /dev/null +++ b/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl @@ -0,0 +1,118 @@ +# Lambda, rather than Packer, owns the MicroVM image build. This single +# pseudo-Packer target provides the same build interface as the AMI pipelines +# while delegating packaging, regional publication, and polling to boto3. +# The null builder and shell-local provisioner are Packer built-ins, so this +# template intentionally has no required_plugins entry for them. + +variable "aws_data_path" { + description = "Botocore data path containing the Lambda MicroVM service model." + type = string + default = env("AWS_DATA_PATH") +} + +variable "aws_region" { + description = "AWS Region for the S3 artifact, Ubuntu ECR mirror, and Lambda MicroVM image." + type = string + default = env("AWS_REGION") +} + +variable "artifact_bucket" { + description = "S3 artifact bucket. Lambda MicroVMs requires this bucket to be in aws_region." + type = string + default = env("MICROVM_ARTIFACT_BUCKET") +} + +variable "build_role_arn" { + description = "IAM role assumed by Lambda while it builds the MicroVM image." + type = string + default = env("MICROVM_BUILD_ROLE_ARN") +} + +variable "egress_network_connector_arn" { + description = "ARN of the regional Lambda Network Connector used for image-build egress." + type = string + default = env("MICROVM_EGRESS_NETWORK_CONNECTOR_ARN") +} + +variable "image_name" { + description = "Name of the customer Lambda MicroVM image." + type = string + default = env("MICROVM_IMAGE_NAME") +} + +variable "idempotency_nonce" { + description = "Per-attempt nonce that permits a workflow rerun to replace an asynchronously failed build." + type = string + default = env("MICROVM_IDEMPOTENCY_NONCE") +} + +variable "lifecycle_hook_zip" { + description = "ZIP containing the compiled lifecycle-hook server.js at the archive root." + type = string + default = env("MICROVM_LIFECYCLE_HOOK_ZIP") +} + +variable "log_group" { + description = "CloudWatch Logs group for the Lambda MicroVM image build." + type = string + default = env("MICROVM_LOG_GROUP") +} + +variable "memory_mib" { + description = "MicroVM memory tier in MiB. The complete runner image currently requires the 8192 MiB tier's 32 GiB disk." + type = string + default = env("MICROVM_MEMORY_MIB") +} + +variable "output_dir" { + description = "Directory for deterministic build artifacts and publication manifests." + type = string + default = env("MICROVM_OUTPUT_DIR") +} + +variable "release_version" { + description = "Stable or prerelease version recorded in MicroVM metadata." + type = string + default = env("MICROVM_RELEASE_VERSION") +} + +variable "ubuntu_image" { + description = "Regional private ECR mirror used for the Ubuntu 24.04 Dockerfile stages." + type = string + default = env("MICROVM_UBUNTU_IMAGE") +} + +source "null" "lambda_microvm" { + communicator = "none" +} + +build { + name = "lambda-microvm-image" + sources = [ + "source.null.lambda_microvm" + ] + + provisioner "shell-local" { + # MICROVM_ENVIRONMENT_VARIABLES is inherited from the build step. Do not + # add it here: shell-local renders environment_vars into the shell argv. + environment_vars = [ + "AWS_DATA_PATH=${var.aws_data_path}", + "AWS_REGION=${var.aws_region}", + "MICROVM_ARTIFACT_BUCKET=${var.artifact_bucket}", + "MICROVM_BUILD_ROLE_ARN=${var.build_role_arn}", + "MICROVM_EGRESS_NETWORK_CONNECTOR_ARN=${var.egress_network_connector_arn}", + "MICROVM_IMAGE_NAME=${var.image_name}", + "MICROVM_IDEMPOTENCY_NONCE=${var.idempotency_nonce}", + "MICROVM_LIFECYCLE_HOOK_ZIP=${var.lifecycle_hook_zip}", + "MICROVM_LOG_GROUP=${var.log_group}", + "MICROVM_MEMORY_MIB=${var.memory_mib}", + "MICROVM_OUTPUT_DIR=${var.output_dir}", + "MICROVM_RELEASE_VERSION=${var.release_version}", + "MICROVM_UBUNTU_IMAGE=${var.ubuntu_image}", + "PYTHONDONTWRITEBYTECODE=1", + "PYTHONUNBUFFERED=1", + ] + script = "packer/scripts/microvm/build-microvm-image.py" + timeout = "90m" + } +} diff --git a/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py b/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py new file mode 100644 index 0000000000..81c13beb2e --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +"""Package and publish the ARM64 Lambda MicroVM runner image.""" + +from __future__ import annotations + +import base64 +import datetime as dt +import hashlib +import json +import os +import re +import stat +import subprocess +import sys +import tempfile +import time +import zipfile +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +from pathlib import PurePosixPath +from typing import Any, Iterable, Mapping + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +IMAGE_ROOT = Path(__file__).resolve().parent / 'image' +OUTPUT_ROOT = REPOSITORY_ROOT / 'output' / 'microvm' +DOCKERFILE = 'ubuntu24.arm64.Dockerfile' +ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0) +WAIT_TIMEOUT_SECONDS = 3000 +EXCLUDED_DIRECTORIES = { + '.cache', + '.git', + '.mypy_cache', + '.pytest_cache', + '.ruff_cache', + '__pycache__', + 'dist', + 'node_modules', +} +EXCLUDED_FILES = {'.DS_Store', '.git'} + + +class BuildError(RuntimeError): + """Expected publication failure.""" + + +@dataclass(frozen=True) +class Settings: + region: str + artifact_bucket: str + build_role_arn: str + egress_network_connector_arn: str + environment_variables: Mapping[str, str] + image_name: str + idempotency_nonce: str + lifecycle_hook_zip: Path + log_group: str + memory_mib: int + output_dir: Path + release_version: str + ubuntu_image: str + + +@dataclass(frozen=True) +class Artifact: + path: Path + sha256: str + + +def environment(name: str, default: str = '') -> str: + return os.environ.get(name, '').strip() or default + + +def load_settings() -> Settings: + return Settings( + region=environment('AWS_REGION'), + artifact_bucket=environment('MICROVM_ARTIFACT_BUCKET'), + build_role_arn=environment('MICROVM_BUILD_ROLE_ARN'), + egress_network_connector_arn=environment( + 'MICROVM_EGRESS_NETWORK_CONNECTOR_ARN' + ), + environment_variables=json.loads( + environment('MICROVM_ENVIRONMENT_VARIABLES', '{}') + ), + image_name=environment('MICROVM_IMAGE_NAME'), + idempotency_nonce=environment('MICROVM_IDEMPOTENCY_NONCE'), + lifecycle_hook_zip=Path( + environment('MICROVM_LIFECYCLE_HOOK_ZIP') + ).resolve(), + log_group=environment('MICROVM_LOG_GROUP'), + memory_mib=int(environment('MICROVM_MEMORY_MIB')), + output_dir=Path( + environment('MICROVM_OUTPUT_DIR', str(OUTPUT_ROOT)) + ).resolve(), + release_version=environment('MICROVM_RELEASE_VERSION'), + ubuntu_image=environment('MICROVM_UBUNTU_IMAGE'), + ) + + +def artifact_files(root: Path) -> Iterable[Path]: + for current_root, directories, files in os.walk(root): + directories[:] = sorted( + name for name in directories if name not in EXCLUDED_DIRECTORIES + ) + current = Path(current_root) + for name in sorted(files): + path = current / name + if all( + ( + name not in EXCLUDED_FILES, + path.suffix not in {'.pyc', '.pyo'}, + path.is_file(), + not path.is_symlink(), + ) + ): + yield path + + +def render_dockerfile(contents: bytes, ubuntu_image: str) -> bytes: + rendered = re.sub( + r'^ARG UBUNTU_IMAGE(?:=.*)?$', + f"ARG UBUNTU_IMAGE={json.dumps(ubuntu_image)}", + contents.decode(), + flags=re.MULTILINE, + ) + return rendered.encode() + + +def validate_lifecycle_hook_zip(path: Path) -> None: + if not path.is_file(): + raise BuildError( + f'MICROVM_LIFECYCLE_HOOK_ZIP must point to a file: {path}' + ) + + try: + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + except (OSError, zipfile.BadZipFile) as error: + raise BuildError( + f'MICROVM_LIFECYCLE_HOOK_ZIP is not a valid ZIP archive: {path}' + ) from error + + files = set() + for member in members: + member_path = PurePosixPath(member.filename) + if member_path.is_absolute() or '..' in member_path.parts: + raise BuildError( + 'MICROVM_LIFECYCLE_HOOK_ZIP contains an unsafe archive path: ' + f'{member.filename}' + ) + if stat.S_IFMT(member.external_attr >> 16) == stat.S_IFLNK: + raise BuildError( + 'MICROVM_LIFECYCLE_HOOK_ZIP must not contain symbolic links: ' + f'{member.filename}' + ) + if not member.filename.endswith('/'): + files.add(member.filename) + + if 'server.js' not in files: + raise BuildError( + 'MICROVM_LIFECYCLE_HOOK_ZIP must contain a compiled server.js ' + 'at the archive root' + ) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as file_handle: + for chunk in iter(lambda: file_handle.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def create_artifact(settings: Settings, ubuntu_image: str) -> Artifact: + validate_lifecycle_hook_zip(settings.lifecycle_hook_zip) + files = [ + ( + 'Dockerfile' + if path == IMAGE_ROOT / DOCKERFILE + else path.relative_to(IMAGE_ROOT).as_posix(), + path, + ) + for path in artifact_files(IMAGE_ROOT) + ] + files.append(('lifecycle-hook.zip', settings.lifecycle_hook_zip)) + files.sort(key=lambda item: item[0]) + settings.output_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory( + prefix='microvm-package-', dir=settings.output_dir + ) as temporary: + temporary_zip = Path(temporary) / 'microvm-image.zip' + with zipfile.ZipFile( + temporary_zip, + mode='w', + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + for archive_name, source in files: + contents = source.read_bytes() + if archive_name == 'Dockerfile': + contents = render_dockerfile(contents, ubuntu_image) + mode = 0o755 if source.stat().st_mode & 0o111 else 0o644 + info = zipfile.ZipInfo(archive_name, ZIP_TIMESTAMP) + info.create_system = 3 + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = (stat.S_IFREG | mode) << 16 + archive.writestr( + info, + contents, + compress_type=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) + + digest = sha256_file(temporary_zip) + artifact_path = settings.output_dir / ( + f"{settings.image_name}-{digest[:12]}.zip" + ) + os.replace(temporary_zip, artifact_path) + return Artifact(artifact_path, digest) + + +def source_revision() -> str: + revision = environment('GITHUB_SHA') or environment('SOURCE_REVISION') + if revision: + return revision[:12] + completed = subprocess.run( + [ + 'git', + '-C', + str(REPOSITORY_ROOT), + 'rev-parse', + '--short=12', + 'HEAD', + ], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def aws_session(region: str) -> Any: + try: + import boto3 # type: ignore[import-not-found] + except ModuleNotFoundError as error: + raise BuildError( + 'boto3 is required to publish the MicroVM image' + ) from error + return boto3.Session(region_name=region) + + +def microvm_client(session: Any, region: str) -> Any: + try: + return session.client('lambda-microvms', region_name=region) + except Exception as error: + if type(error).__name__ == 'UnknownServiceError': + raise BuildError( + 'AWS_DATA_PATH must contain the Lambda MicroVM service model' + ) from error + raise + + +def resolve_ubuntu_image(ecr: Any, image: str) -> str: + if '@' in image: + return image + repository_uri, tag = image.rsplit(':', 1) + registry, repository = repository_uri.split('/', 1) + account = registry.split('.', 1)[0] + response = ecr.describe_images( + registryId=account, + repositoryName=repository, + imageIds=[{'imageTag': tag}], + ) + digest = response['imageDetails'][0]['imageDigest'] + return f"{repository_uri}@{digest}" + + +def upload_artifact( + s3: Any, settings: Settings, artifact: Artifact, revision: str +) -> str: + key = f"lambda-microvms/artifacts/{artifact.sha256}.zip" + checksum = base64.b64encode(bytes.fromhex(artifact.sha256)).decode() + with artifact.path.open('rb') as file_handle: + s3.put_object( + Bucket=settings.artifact_bucket, + Key=key, + Body=file_handle, + ChecksumSHA256=checksum, + ContentType='application/zip', + Metadata={ + 'sha256': artifact.sha256, + 'source-revision': revision, + }, + ) + return f"s3://{settings.artifact_bucket}/{key}" + + +def find_image(client: Any, name: str) -> str: + request: dict[str, Any] = {'maxResults': 50, 'nameFilter': name} + while True: + response = client.list_microvm_images(**request) + for image in response.get('items', []): + if image.get('name') == name: + return str(image['imageArn']) + token = response.get('nextToken') + if not token: + return '' + request['nextToken'] = token + + +def log_stream(settings: Settings) -> str: + if settings.idempotency_nonce: + return f"{settings.image_name}/{settings.idempotency_nonce}" + return settings.image_name + + +def build_request( + settings: Settings, + artifact_uri: str, + revision: str, + image_arn: str, +) -> dict[str, Any]: + operation = 'update' if image_arn else 'create' + description = f"Ephemeral GitHub Actions runner from {revision}" + if settings.release_version: + description = ( + f"Ephemeral GitHub Actions runner release " + f"{settings.release_version} from {revision}" + ) + request: dict[str, Any] = { + 'additionalOsCapabilities': ['ALL'], + 'baseImageArn': ( + f"arn:aws:lambda:{settings.region}:aws:microvm-image:al2023-1" + ), + 'buildRoleArn': settings.build_role_arn, + 'codeArtifact': {'uri': artifact_uri}, + 'cpuConfigurations': [{'architecture': 'ARM_64'}], + 'description': description, + 'egressNetworkConnectors': [settings.egress_network_connector_arn], + 'environmentVariables': dict(settings.environment_variables), + 'hooks': { + 'port': 8080, + 'microvmHooks': { + 'run': 'ENABLED', + 'runTimeoutInSeconds': 60, + 'terminate': 'ENABLED', + 'terminateTimeoutInSeconds': 60, + }, + 'microvmImageHooks': { + 'ready': 'ENABLED', + 'readyTimeoutInSeconds': 120, + 'validate': 'ENABLED', + 'validateTimeoutInSeconds': 120, + }, + }, + 'logging': { + 'cloudWatch': { + 'logGroup': settings.log_group, + 'logStream': log_stream(settings), + } + }, + 'resources': [{'minimumMemoryInMiB': settings.memory_mib}], + } + if operation == 'create': + request['name'] = settings.image_name + else: + request['imageIdentifier'] = image_arn + + canonical = json.dumps(request, sort_keys=True, separators=(',', ':')) + request['clientToken'] = hashlib.sha256( + ( + f"{settings.region}|{operation}|{settings.idempotency_nonce}|" + f"{canonical}" + ).encode() + ).hexdigest() + return request + + +def start_build(client: Any, request: Mapping[str, Any]) -> dict[str, Any]: + if 'imageIdentifier' in request: + print('Starting Lambda MicroVM image update') + return client.update_microvm_image(**request) + print('Starting Lambda MicroVM image create') + return client.create_microvm_image(**request) + + +def wait_for_image( + client: Any, image_arn: str, image_version: str +) -> tuple[dict[str, Any], dict[str, Any]]: + deadline = time.monotonic() + WAIT_TIMEOUT_SECONDS + last_state: tuple[str, str, str] | None = None + while time.monotonic() < deadline: + try: + version = client.get_microvm_image_version( + imageIdentifier=image_arn, + imageVersion=image_version, + ) + except Exception as error: + response = getattr(error, 'response', {}) + error_code = response.get('Error', {}).get('Code') + if error_code == 'ResourceNotFoundException': + time.sleep(10) + continue + raise + + state = str(version.get('state', 'UNKNOWN')) + status = str(version.get('status', 'UNKNOWN')) + image: dict[str, Any] = {} + image_state = 'UNKNOWN' + if state == 'SUCCESSFUL': + image = client.get_microvm_image(imageIdentifier=image_arn) + image_state = str(image.get('state', 'UNKNOWN')) + observed = (state, status, image_state) + if observed != last_state: + print( + f"MicroVM image version {image_version}: state={state} " + f"status={status} image_state={image_state}" + ) + last_state = observed + if state == 'FAILED': + raise BuildError( + 'MicroVM image build failed: ' + f"{version.get('stateReason', 'no reason returned')}" + ) + if state == 'SUCCESSFUL' and status == 'ACTIVE' and image_state in { + 'CREATED', + 'UPDATED', + }: + return image, version + time.sleep(10) + raise BuildError( + f"timed out waiting for MicroVM image after " + f"{WAIT_TIMEOUT_SECONDS} seconds" + ) + + +def print_build_logs( + logs: Any, settings: Settings, start_time_ms: int +) -> None: + request: dict[str, Any] = { + 'logGroupName': settings.log_group, + 'logStreamNames': [log_stream(settings)], + 'startTime': start_time_ms, + } + while True: + response = logs.filter_log_events(**request) + for event in response.get('events', []): + timestamp = ( + dt.datetime.fromtimestamp( + int(event['timestamp']) / 1000, + tz=dt.timezone.utc, + ) + .isoformat(timespec='milliseconds') + .replace('+00:00', 'Z') + ) + message = str(event.get('message', '')).rstrip() + print(f"[microvm-build {timestamp}] {message}") + token = response.get('nextToken') + if not token or token == request.get('nextToken'): + return + request['nextToken'] = token + + +def json_value(value: Any) -> Any: + if isinstance(value, (dt.date, dt.datetime)): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + raise TypeError(f"{type(value).__name__} is not JSON serializable") + + +def write_manifest(path: Path, value: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode='w', + encoding='utf-8', + dir=path.parent, + delete=False, + ) as temporary: + json.dump( + value, + temporary, + default=json_value, + indent=2, + sort_keys=True, + ) + temporary.write('\n') + temporary_path = Path(temporary.name) + os.replace(temporary_path, path) + + +def run() -> int: + settings = load_settings() + revision = source_revision() + session = aws_session(settings.region) + ecr = session.client('ecr', region_name=settings.region) + ubuntu_image = resolve_ubuntu_image(ecr, settings.ubuntu_image) + print(f"Using digest-pinned Ubuntu mirror: {ubuntu_image}") + + artifact = create_artifact(settings, ubuntu_image) + print(f"Packaged MicroVM artifact: {artifact.path}") + print(f"Artifact SHA-256: {artifact.sha256}") + + s3 = session.client('s3', region_name=settings.region) + artifact_uri = upload_artifact(s3, settings, artifact, revision) + print(f"Uploaded {artifact_uri}") + + client = microvm_client(session, settings.region) + existing_image_arn = find_image(client, settings.image_name) + request = build_request( + settings, + artifact_uri, + revision, + existing_image_arn, + ) + started_at = int(time.time() * 1000) - 5000 + response = start_build(client, request) + image_arn = str(response['imageArn']) + image_version = str(response['imageVersion']) + + manifest = { + 'artifactSha256': artifact.sha256, + 'artifactUri': artifact_uri, + 'egressNetworkConnectorArn': settings.egress_network_connector_arn, + 'imageArn': image_arn, + 'imageVersion': image_version, + 'logGroup': settings.log_group, + 'logStream': log_stream(settings), + 'name': settings.image_name, + 'operation': 'update' if existing_image_arn else 'create', + 'region': settings.region, + 'releaseVersion': settings.release_version, + 'sourceRevision': revision, + 'ubuntuBaseImage': ubuntu_image, + } + manifest_path = settings.output_dir / 'microvm-image.json' + write_manifest(manifest_path, manifest) + + try: + image, version = wait_for_image(client, image_arn, image_version) + finally: + try: + print_build_logs( + session.client('logs', region_name=settings.region), + settings, + started_at, + ) + except Exception as error: + print( + f"Warning: could not retrieve build logs: {error}", + file=sys.stderr, + ) + + manifest.update( + { + 'imageState': image.get('state'), + 'state': version.get('state'), + 'status': version.get('status'), + } + ) + write_manifest(manifest_path, manifest) + print( + f"Lambda MicroVM image is ready: " + f"{image_arn} version {image_version}" + ) + print(f"Manifest: {manifest_path}") + return 0 + + +def main() -> int: + try: + return run() + except KeyboardInterrupt: + print('Error: interrupted', file=sys.stderr) + return 130 + except Exception as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/.dockerignore b/images/microvm-ubuntu/packer/scripts/microvm/image/.dockerignore new file mode 100644 index 0000000000..7a60b85e14 --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/.dockerignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/image-entrypoint.sh b/images/microvm-ubuntu/packer/scripts/microvm/image/image-entrypoint.sh new file mode 100644 index 0000000000..5313aff275 --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/image-entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# shellcheck shell=bash + +# Start the compiled lifecycle-hook server from the supplied ZIP artifact. + +set -euo pipefail + +readonly hook_node="${MICROVM_HOOK_NODE:-/opt/actions-runner/externals/node24/bin/node}" +readonly hook_server="${MICROVM_HOOK_SERVER:-/opt/microvm/server.js}" + +if [[ ! -x "$hook_node" ]]; then + printf '[microvm] Lifecycle hook Node executable is unavailable: %s\n' \ + "$hook_node" >&2 + exit 1 +fi +if [[ ! -r "$hook_server" ]]; then + printf '[microvm] Lifecycle hook server is unavailable: %s\n' \ + "$hook_server" >&2 + exit 1 +fi + +exec "$hook_node" "$hook_server" diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/services/cloudwatch-agent.sh b/images/microvm-ubuntu/packer/scripts/microvm/image/services/cloudwatch-agent.sh new file mode 100644 index 0000000000..1924c7fcd8 --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/services/cloudwatch-agent.sh @@ -0,0 +1,49 @@ +#!/command/with-contenv bash +# shellcheck shell=bash + +set -euo pipefail + +readonly agent_root=/opt/aws/amazon-cloudwatch-agent +readonly config_directory=/etc/cwagentconfig +readonly config_path="${config_directory}/config.json" +readonly microvm_id="${MICROVM_ID:?}" +readonly runner_config_ssm_path="${RUNNER_CONFIG_SSM_PATH:?}" + +read_parameter() { + AWS_PAGER='' /usr/local/bin/aws ssm get-parameter \ + --name "$1" \ + --query Parameter.Value \ + --output text \ + --no-cli-pager +} + +enabled="$(read_parameter "${runner_config_ssm_path}/enable_cloudwatch")" +if [[ "$enabled" == false ]]; then + printf '[cloudwatch-agent] disabled by runner configuration\n' >&2 + /command/s6-svc -d /run/service/cloudwatch-agent + exit 0 +fi +if [[ "$enabled" != true ]]; then + printf '[cloudwatch-agent] enable_cloudwatch must be true or false\n' >&2 + exit 1 +fi + +install -d -m 0700 -o root -g root "$config_directory" +umask 077 +read_parameter "${runner_config_ssm_path}/cloudwatch_agent_config_runner" | + MICROVM_ID="$microvm_id" jq --exit-status ' + select(type == "object") | + walk( + if type == "string" then + gsub("\\{microvm_id\\}"; env.MICROVM_ID) + else + . + end + ) +' >"$config_path" +chmod 0600 "$config_path" + +exec env \ + RUN_IN_AWS=True \ + RUN_IN_CONTAINER=True \ + "${agent_root}/bin/start-amazon-cloudwatch-agent" diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/start-services.sh b/images/microvm-ubuntu/packer/scripts/microvm/image/start-services.sh new file mode 100644 index 0000000000..5865ed1758 --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/start-services.sh @@ -0,0 +1,39 @@ +#!/command/with-contenv bash +# shellcheck shell=bash + +set -euo pipefail + +readonly internal_services_log=/var/log/microvm/internal-services.log +readonly microvm_id="${MICROVM_ID:?}" +readonly runner_config_ssm_path="${RUNNER_CONFIG_SSM_PATH:?}" +readonly s6_environment=/run/s6/container_environment + +exec > >(/usr/bin/tee --append -- "$internal_services_log") +exec 2> >(/usr/bin/tee --append -- "$internal_services_log" >&2) + +if [[ -z "${MICROVM_SERVICES:-}" ]]; then + exit 0 +fi + +IFS=',' read -r -a services <<<"${MICROVM_SERVICES}" +for service in "${services[@]}"; do + [[ -z "$service" ]] && continue + if [[ ! "$service" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]]; then + printf '[microvm-services] invalid service name: %s\n' "$service" >&2 + exit 2 + fi + if [[ ! -d "/run/service/${service}" ]]; then + printf '[microvm-services] service is unavailable: %s\n' "$service" >&2 + exit 1 + fi +done + +printf '%s' "$microvm_id" >"${s6_environment}/MICROVM_ID" +chmod 0600 "${s6_environment}/MICROVM_ID" +printf '%s' "$runner_config_ssm_path" >"${s6_environment}/RUNNER_CONFIG_SSM_PATH" +chmod 0600 "${s6_environment}/RUNNER_CONFIG_SSM_PATH" + +for service in "${services[@]}"; do + [[ -z "$service" ]] && continue + /command/s6-svc -u "/run/service/${service}" +done diff --git a/images/microvm-ubuntu/packer/scripts/microvm/image/ubuntu24.arm64.Dockerfile b/images/microvm-ubuntu/packer/scripts/microvm/image/ubuntu24.arm64.Dockerfile new file mode 100644 index 0000000000..c60fc1316e --- /dev/null +++ b/images/microvm-ubuntu/packer/scripts/microvm/image/ubuntu24.arm64.Dockerfile @@ -0,0 +1,174 @@ +# syntax=docker/dockerfile:1 + +# Lambda MicroVMs currently run ARM64 images. The image contains the Actions +# runner, CloudWatch Agent, S6 overlay, and compiled lifecycle-hook server. +ARG UBUNTU_IMAGE + +# hadolint ignore=DL3006 +FROM ${UBUNTU_IMAGE} AS tooling + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ARG AWS_CLI_VERSION="2.36.24" +ARG AWS_CLI_SHA256=c024c45a9d22005f81c7c0fab9e23ee7118ffa210d812845b42e980cf93727a7 + +ARG RUNNER_VERSION="2.336.0" +ARG RUNNER_SHA256=58b758e420b87093fbd4bfddd368074960053e2f1388f01848c82624b90f27d1 + +ARG CLOUDWATCH_AGENT_VERSION=1.300071.0b1720 + +# S6 overlay is pinned and verified before it is copied into the runtime image. +ARG S6_OVERLAY_VERSION="3.2.3.2" +ARG S6_OVERLAY_NOARCH_SHA256=5379750ed30a84bbd2e2dd74847ba6b5bd29cd0b2e3ea2ec58049b57eb2eda12 +ARG S6_OVERLAY_AARCH64_SHA256=b17f17a82e7a515c682a91edaf2ffdabb73f891981b6c1fd712115693a2f8b4c + +# These packages are used only while assembling the runtime payload. +# hadolint ignore=DL3008,DL3015 +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + tar \ + unzip \ + xz-utils \ + && rm -rf /var/lib/apt/lists/* + +RUN install -d -m 0755 \ + /export/usr/local/aws-cli \ + /export/usr/local/bin \ + /export/opt/actions-runner \ + /export/opt/microvm \ + /export/run/amazon \ + /export/s6 \ + && curl --fail --location --show-error --silent \ + "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz" \ + --output /tmp/actions-runner.tar.gz \ + && printf '%s %s\n' "${RUNNER_SHA256}" /tmp/actions-runner.tar.gz | sha256sum --check --strict \ + && tar --extract --gzip --no-same-owner --file /tmp/actions-runner.tar.gz \ + --directory /export/opt/actions-runner \ + && test -x /export/opt/actions-runner/externals/node24/bin/node \ + && rm -f /tmp/actions-runner.tar.gz + +RUN curl --fail --location --show-error --silent \ + "https://amazoncloudwatch-agent.s3.amazonaws.com/ubuntu/arm64/${CLOUDWATCH_AGENT_VERSION}/amazon-cloudwatch-agent.deb" \ + --output /tmp/amazon-cloudwatch-agent.deb \ + && install -d -m 0755 /tmp/cloudwatch-agent-root \ + && dpkg-deb --extract /tmp/amazon-cloudwatch-agent.deb /tmp/cloudwatch-agent-root \ + && test "$(cat /tmp/cloudwatch-agent-root/opt/aws/amazon-cloudwatch-agent/bin/CWAGENT_VERSION)" \ + = "${CLOUDWATCH_AGENT_VERSION}" \ + && install -d -m 0755 /tmp/cloudwatch-agent-root/run/amazon \ + && mv /tmp/cloudwatch-agent-root/var/run/amazon/amazon-cloudwatch-agent \ + /tmp/cloudwatch-agent-root/run/amazon/ \ + && rmdir /tmp/cloudwatch-agent-root/var/run/amazon /tmp/cloudwatch-agent-root/var/run \ + && cp -a /tmp/cloudwatch-agent-root/. /export/ \ + && rm -f /tmp/amazon-cloudwatch-agent.deb \ + && rm -rf /tmp/cloudwatch-agent-root /export/etc/init /export/etc/systemd + +RUN curl --fail --location --show-error --silent \ + "https://awscli.amazonaws.com/awscli-exe-linux-aarch64-${AWS_CLI_VERSION}.zip" \ + --output /tmp/awscliv2.zip \ + && printf '%s %s\n' "${AWS_CLI_SHA256}" /tmp/awscliv2.zip | sha256sum --check --strict \ + && unzip -q /tmp/awscliv2.zip -d /tmp \ + && /tmp/aws/install \ + --install-dir /export/usr/local/aws-cli \ + --bin-dir /export/usr/local/bin \ + && rm -f /export/usr/local/bin/aws /export/usr/local/bin/aws_completer \ + && ln -s ../aws-cli/v2/current/bin/aws /export/usr/local/bin/aws \ + && ln -s ../aws-cli/v2/current/bin/aws_completer /export/usr/local/bin/aws_completer \ + && rm -rf /tmp/aws /tmp/awscliv2.zip + +RUN curl --fail --location --show-error --silent \ + "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" \ + --output /tmp/s6-overlay-noarch.tar.xz \ + && curl --fail --location --show-error --silent \ + "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-aarch64.tar.xz" \ + --output /tmp/s6-overlay-aarch64.tar.xz \ + && printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" \ + /tmp/s6-overlay-noarch.tar.xz | sha256sum --check --strict \ + && printf '%s %s\n' "${S6_OVERLAY_AARCH64_SHA256}" \ + /tmp/s6-overlay-aarch64.tar.xz | sha256sum --check --strict \ + && tar --extract --xz --preserve-permissions --file /tmp/s6-overlay-noarch.tar.xz \ + --directory /export \ + && tar --extract --xz --preserve-permissions --file /tmp/s6-overlay-aarch64.tar.xz \ + --directory /export \ + && rm -f /tmp/s6-overlay-noarch.tar.xz /tmp/s6-overlay-aarch64.tar.xz + +COPY lifecycle-hook.zip /tmp/lifecycle-hook.zip +RUN unzip -q /tmp/lifecycle-hook.zip -d /export/opt/microvm \ + && test -r /export/opt/microvm/server.js \ + && rm -f /tmp/lifecycle-hook.zip + +FROM ${UBUNTU_IMAGE} + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# These are the Actions runner runtime dependencies. Keep the list aligned +# with the runner's supported Ubuntu dependencies. +# hadolint ignore=DL3008,DL3015 +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + jq \ + libicu74 \ + libkrb5-3 \ + liblttng-ust1t64 \ + libssl3t64 \ + zlib1g \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=tooling /export/ / + +RUN existing_group="$(getent group 1000 | cut -d: -f1)" \ + && if [ -n "${existing_group}" ]; then \ + groupmod --new-name runner "${existing_group}"; \ + else \ + groupadd --gid 1000 runner; \ + fi \ + && existing_user="$(getent passwd 1000 | cut -d: -f1)" \ + && if [ -n "${existing_user}" ]; then \ + usermod --login runner --home /home/runner --move-home \ + --shell /bin/bash "${existing_user}"; \ + else \ + useradd --create-home --home-dir /home/runner --shell /bin/bash \ + --uid 1000 --gid 1000 runner; \ + fi \ + && install -d -m 0755 /opt/microvm /etc/services.d/cloudwatch-agent /var/log/microvm \ + && install -m 0600 /dev/null /var/log/microvm/internal-services.log \ + && install -m 0600 /dev/null /var/log/microvm/run.log \ + && chown -R runner:runner /home/runner /opt/actions-runner + +COPY --chmod=0555 image-entrypoint.sh /opt/microvm/image-entrypoint.sh +COPY --chmod=0555 start-services.sh /opt/microvm/start-services.sh +COPY --chmod=0755 services/cloudwatch-agent.sh /etc/services.d/cloudwatch-agent/run +RUN touch /etc/services.d/cloudwatch-agent/down \ + && chmod 0644 /etc/services.d/cloudwatch-agent/down + +ENV ACTIONS_RUNNER_ROOT="/opt/actions-runner" \ + AGENT_TOOLSDIRECTORY="/opt/hostedtoolcache" \ + HOME="/home/runner" \ + HOOK_PORT="8080" \ + INTERNAL_SERVICES="/opt/microvm/start-services.sh" \ + MICROVM_HOOK_LOG_FILE="/var/log/microvm/run.log" \ + MICROVM_HOOK_NODE="/opt/actions-runner/externals/node24/bin/node" \ + MICROVM_HOOK_SERVER="/opt/microvm/server.js" \ + MICROVM_SERVICES="cloudwatch-agent" \ + RUN_HOOK_TIMEOUT_SECONDS="52" \ + RUNNER_CONFIG_POLL_SECONDS="2" \ + RUNNER_CONFIG_TIMEOUT_SECONDS="20" \ + RUNNER_GID="1000" \ + RUNNER_HOME="/home/runner" \ + RUNNER_LAUNCH_RESERVE_SECONDS="7" \ + RUNNER_ROOT="/opt/actions-runner" \ + RUNNER_UID="1000" \ + RUNNER_USER="runner" \ + RUNNER_TOOL_CACHE="/opt/hostedtoolcache" \ + RUNNER_TOOLSDIRECTORY="/opt/hostedtoolcache" + +# The lifecycle hook owns the MicroVM control socket and log file. +# hadolint ignore=DL3002 +USER 0 +WORKDIR /opt/actions-runner +EXPOSE 8080 +ENTRYPOINT ["/init"] +CMD ["/command/with-contenv", "/opt/microvm/image-entrypoint.sh"] diff --git a/modules/microvm-foundation/README.md b/modules/microvm-foundation/README.md index fadbedc0f7..2c56e7fa8f 100644 --- a/modules/microvm-foundation/README.md +++ b/modules/microvm-foundation/README.md @@ -51,7 +51,7 @@ module "microvm_foundation" { The companion `examples/microvm-foundation` directory is a complete setup example. Apply it before following the direct Packer build instructions in -`images/microvm/README.md` or using the `examples/microvm` runner example. +`images/microvm-ubuntu/README.md` or using the `examples/microvm` runner example. ## Requirements diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index b632ee52aa..635e078570 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -102,7 +102,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -110,7 +110,7 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | 6.63.0 | | [random](#provider\_random) | 3.9.0 | | [terraform](#provider\_terraform) | n/a | @@ -118,7 +118,7 @@ module "multi-runner" { ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -130,7 +130,7 @@ module "multi-runner" { ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -143,7 +143,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

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

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | @@ -235,7 +235,7 @@ module "multi-runner" { ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index e7dfd50d63..e7ed1fb14a 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -119,7 +119,7 @@ yarn run dist |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | -| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.

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

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

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

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

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

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