From c0219194233cda9044c1f406ce9392387d4ba170 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 23:25:58 +0200 Subject: [PATCH 1/6] feat(microvm): add image build example --- .github/workflows/packer-build.yml | 2 +- .github/workflows/terraform.yml | 3 +- docs/examples/microvm.md | 3 + examples/microvm-foundation/README.md | 2 +- examples/microvm/.terraform.lock.hcl | 68 ++ examples/microvm/README.md | 102 +++ examples/microvm/main.tf | 110 ++++ examples/microvm/outputs.tf | 9 + examples/microvm/providers.tf | 9 + examples/microvm/variables.tf | 95 +++ examples/microvm/versions.tf | 9 + images/README.md | 13 + images/microvm-ubuntu/README.md | 46 ++ .../github_agent.microvm.ubuntu.pkr.hcl | 118 ++++ .../scripts/microvm/build-microvm-image.py | 583 ++++++++++++++++++ .../scripts/microvm/image/.dockerignore | 2 + .../scripts/microvm/image/image-entrypoint.sh | 22 + .../image/services/cloudwatch-agent.sh | 49 ++ .../scripts/microvm/image/start-services.sh | 39 ++ .../microvm/image/ubuntu24.arm64.Dockerfile | 174 ++++++ modules/microvm-foundation/README.md | 2 +- 21 files changed, 1456 insertions(+), 4 deletions(-) create mode 100644 docs/examples/microvm.md create mode 100644 examples/microvm/.terraform.lock.hcl create mode 100644 examples/microvm/README.md create mode 100644 examples/microvm/main.tf create mode 100644 examples/microvm/outputs.tf create mode 100644 examples/microvm/providers.tf create mode 100644 examples/microvm/variables.tf create mode 100644 examples/microvm/versions.tf create mode 100644 images/microvm-ubuntu/README.md create mode 100644 images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/.dockerignore create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/image-entrypoint.sh create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/services/cloudwatch-agent.sh create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/start-services.sh create mode 100644 images/microvm-ubuntu/packer/scripts/microvm/image/ubuntu24.arm64.Dockerfile diff --git a/.github/workflows/packer-build.yml b/.github/workflows/packer-build.yml index 8dcff4efb6..726d70e9d3 100644 --- a/.github/workflows/packer-build.yml +++ b/.github/workflows/packer-build.yml @@ -28,7 +28,7 @@ jobs: image: index.docker.io/hashicorp/packer@sha256:12c441b8a3994e7df9f0e2692d9298f14c387e70bcc06139420977dbf80a137b # 1.11.2 strategy: matrix: - image: ["linux-al2023", "windows-core-2019", "windows-core-2022", "ubuntu-focal", "ubuntu-jammy", "ubuntu-jammy-arm64"] + image: ["linux-al2023", "windows-core-2019", "windows-core-2022", "ubuntu-focal", "ubuntu-jammy", "ubuntu-jammy-arm64", "microvm-ubuntu"] defaults: run: working-directory: images/${{ matrix.image }} diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index f89bdfe742..e046edb9d7 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 bdc531bc12..4ceb112d29 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 77b620c107..15747640f8 100644 --- a/modules/microvm-foundation/README.md +++ b/modules/microvm-foundation/README.md @@ -51,7 +51,7 @@ module "microvm_foundation" { The companion `examples/microvm-foundation` directory is a complete setup example. Apply it before following the direct Packer build instructions in -`images/microvm/README.md` or using the `examples/microvm` runner example. +`images/microvm-ubuntu/README.md` or using the `examples/microvm` runner example. ## Requirements From d3349f8cf400b0144d6adaceb0fc9527fa931771 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 21:25:45 +0200 Subject: [PATCH 2/6] test(ministack): run microvm example --- .github/workflows/ministack.yml | 1 + examples/microvm/main.tf | 18 ++++-------- tests/ministack/README.md | 8 +++++- tests/ministack/microvm.tfvars | 21 ++++++++++++++ tests/ministack/run-example.sh | 51 +++++++++++++++++++++++++++++++-- 5 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 tests/ministack/microvm.tfvars diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 5e3ec7e441..0ae55b3048 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -55,6 +55,7 @@ jobs: - multi-runner - multi-runner-v2 - microvm-foundation + - microvm - termination-watcher terraform: - "1.4.0" diff --git a/examples/microvm/main.tf b/examples/microvm/main.tf index e3b2aaa6b4..105c278b96 100644 --- a/examples/microvm/main.tf +++ b/examples/microvm/main.tf @@ -18,20 +18,14 @@ module "runners" { subnet_ids = module.base.vpc.private_subnets prefix = local.environment - # Required for backwards-compatible module input validation; the non-empty - # experimental map selects the MicroVM configuration below. - multi_runner_config = {} - # Keep GitHub App credentials in pre-created SSM parameters. This example # therefore does not place the private key or webhook secret in Terraform # configuration or state. - github_app = var.github_app - - experimental_global_config_github = { + global_config_github = { app = var.github_app } - experimental_global_config_lambda = { + global_config_lambda = { artifact = { s3 = { bucket = var.lambda_artifact_bucket @@ -39,7 +33,7 @@ module "runners" { } } - experimental_global_config_orchestration_provider = { + global_config_orchestration_provider = { webhook = { runner = { ephemeral = true @@ -67,13 +61,13 @@ module "runners" { } } - experimental_global_config_ssm = { + global_config_ssm = { paths = { root = "/github-action-runners/${local.environment}" } } - experimental_global_config_compute_provider = { + global_config_compute_provider = { aws = { microvm = { image_arn = var.microvm_image_arn @@ -84,7 +78,7 @@ module "runners" { } } - experimental_multi_runner_config = { + multi_runner_config = { microvm = { runner = { os = "linux" diff --git a/tests/ministack/README.md b/tests/ministack/README.md index eb35b954a7..455f85bab1 100644 --- a/tests/ministack/README.md +++ b/tests/ministack/README.md @@ -1,7 +1,8 @@ # MiniStack example tests The MiniStack workflow runs the `base`, `prebuilt`, `default`, `ephemeral`, -`multi-runner`, `multi-runner-v2`, `microvm-foundation`, and `termination-watcher` examples directly +`multi-runner`, `multi-runner-v2`, `microvm-foundation`, `microvm`, and +`termination-watcher` examples directly with Terraform 1.4.0 and the latest Terraform release. The examples with input variables get their inputs from their own tfvars files in this directory. The `termination-watcher` example has no input variables @@ -12,6 +13,9 @@ Connector API. No override files or setup module are checked in. The helper creates and removes a temporary AMI override for `default` and `ephemeral`, temporary SSM parameters for `multi-runner`, and temporary AMI fixtures plus an override for `multi-runner-v2`. +The `microvm` lane seeds test-only GitHub App SSM parameters and Lambda ZIP +objects in a temporary S3 bucket, and uses synthetic MicroVM image and network +connector ARNs. It does not create a real MicroVM image or network connector. Start MiniStack, set the AWS endpoint and test credentials, then run: @@ -30,6 +34,8 @@ tests/ministack/run-example.sh apply multi-runner-v2 # or tests/ministack/run-example.sh apply microvm-foundation # or +tests/ministack/run-example.sh apply microvm +# or tests/ministack/run-example.sh apply termination-watcher ``` diff --git a/tests/ministack/microvm.tfvars b/tests/ministack/microvm.tfvars new file mode 100644 index 0000000000..b83956b548 --- /dev/null +++ b/tests/ministack/microvm.tfvars @@ -0,0 +1,21 @@ +aws_region = "eu-west-1" +environment = "microvm-ministack" + +github_app = { + key_base64_ssm = { + name = "/ministack/microvm/github-app-key" + arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/microvm/github-app-key" + } + id_ssm = { + name = "/ministack/microvm/github-app-id" + arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/microvm/github-app-id" + } + webhook_secret_ssm = { + name = "/ministack/microvm/webhook-secret" + arn = "arn:aws:ssm:eu-west-1:000000000000:parameter/ministack/microvm/webhook-secret" + } +} + +lambda_artifact_bucket = "github-actions-runner-microvm-ministack" +microvm_image_arn = "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" +egress_network_connector_arn = "arn:aws:lambda:eu-west-1:000000000000:network-connector:ministack" diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index 620d9b2ab4..73e5866ea0 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -14,14 +14,14 @@ example="${2:-}" tfvars_file="${3:-${MINISTACK_TFVARS_FILE:-}}" case "$example" in - base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation) + base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation | microvm) use_tfvars=true ;; termination-watcher) use_tfvars=false ;; *) - echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, microvm-foundation, termination-watcher" >&2 + echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, microvm-foundation, microvm, termination-watcher" >&2 exit 64 ;; esac @@ -29,7 +29,7 @@ esac case "$action" in init | plan | apply | destroy) ;; *) - echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|microvm-foundation|termination-watcher} [TFVARS_FILE]" >&2 + echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|microvm-foundation|microvm|termination-watcher} [TFVARS_FILE]" >&2 exit 64 ;; esac @@ -59,6 +59,7 @@ lambda_fixture_dir="" lambda_created_paths="" ami_created_ids="" ssm_created_names="" +s3_created_buckets="" override_created_paths="" lambda_zip_paths=" $source_root/lambdas/functions/ami-housekeeper/ami-housekeeper.zip @@ -77,6 +78,12 @@ cleanup() { ministack_aws ssm delete-parameter --name "$name" >/dev/null 2>&1 || true done + for bucket in $s3_created_buckets; do + ministack_aws s3api delete-object --bucket "$bucket" --key runners.zip >/dev/null 2>&1 || true + ministack_aws s3api delete-object --bucket "$bucket" --key webhook.zip >/dev/null 2>&1 || true + ministack_aws s3api delete-bucket --bucket "$bucket" >/dev/null 2>&1 || true + done + for image_id in $ami_created_ids; do ministack_aws ec2 deregister-image --image-id "$image_id" >/dev/null 2>&1 || true done @@ -150,6 +157,25 @@ create_ssm_fixture() { $name" } +create_s3_fixture() { + bucket="$1" + key="$2" + file="$3" + + if ! ministack_aws s3api head-bucket --bucket "$bucket" >/dev/null 2>&1; then + ministack_aws s3api create-bucket \ + --bucket "$bucket" \ + --create-bucket-configuration LocationConstraint="$AWS_DEFAULT_REGION" >/dev/null + s3_created_buckets="$s3_created_buckets +$bucket" + fi + + ministack_aws s3api put-object \ + --bucket "$bucket" \ + --key "$key" \ + --body "$file" >/dev/null +} + create_ami_override() { override_file="$example_root/zz_ministack_ami_override.tf" printf '%s\n' \ @@ -356,6 +382,25 @@ $lambda_zip" create_ami_fixture "ministack-v2-linux-x64" x86_64 >/dev/null create_ami_fixture "ministack-v2-windows-x64" x86_64 >/dev/null ;; + microvm) + create_ssm_fixture \ + "/ministack/microvm/github-app-key" \ + "test-only" + create_ssm_fixture \ + "/ministack/microvm/github-app-id" \ + "123456" + create_ssm_fixture \ + "/ministack/microvm/webhook-secret" \ + "test-only" + create_s3_fixture \ + "github-actions-runner-microvm-ministack" \ + "runners.zip" \ + "$lambda_fixture_dir/ministack-lambda.zip" + create_s3_fixture \ + "github-actions-runner-microvm-ministack" \ + "webhook.zip" \ + "$lambda_fixture_dir/ministack-lambda.zip" + ;; esac } From 51e11b6909d81dda2b42b3a55b0a94748e1334fe Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 23:44:51 +0200 Subject: [PATCH 3/6] chore(microvm): add Linux provider checksums --- examples/microvm/.terraform.lock.hcl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/microvm/.terraform.lock.hcl b/examples/microvm/.terraform.lock.hcl index 7a131aab93..e46d40b514 100644 --- a/examples/microvm/.terraform.lock.hcl +++ b/examples/microvm/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.63.0" constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" hashes = [ + "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=", "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=", "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611", "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d", @@ -29,6 +30,7 @@ provider "registry.terraform.io/hashicorp/null" { version = "3.3.1" constraints = "~> 3.0, ~> 3.2" hashes = [ + "h1:TuxJq10DVnRP7c5HBZPyyvQGcckNVfijyU1eXEu5e4M=", "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=", "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44", "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451", @@ -51,6 +53,7 @@ provider "registry.terraform.io/hashicorp/random" { constraints = "~> 3.0" hashes = [ "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", From d191982edc85dca4e6c28046f6c0087103838ffd Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 23:50:23 +0200 Subject: [PATCH 4/6] Revert "docs(adr): clarify runner-config boundary" This reverts commit 182149eed3362f2b1dad6d6e70eacce939194d28. --- ...-runner-orchestration-provider-boundary.md | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/docs/adr/002-runner-orchestration-provider-boundary.md b/docs/adr/002-runner-orchestration-provider-boundary.md index 06dc16a875..3d77e2f3cd 100644 --- a/docs/adr/002-runner-orchestration-provider-boundary.md +++ b/docs/adr/002-runner-orchestration-provider-boundary.md @@ -229,36 +229,6 @@ not below `modules/runner-config`. This keeps the common composition module small and prevents provider-owned resources from becoming part of the common contract. -### `runner-config` is the provider-neutral composition boundary - -`modules/runner-config` is an internal composition module selected by -`multi-runner`; it is not a standalone public entry point. It receives one -resolved runner configuration and owns the common runner identity, IAM role, -runner bootstrap parameters, SSM housekeeper composition, and the capability -connections between the selected providers. - -`runner-config` dispatches exactly one typed orchestration provider and one -typed compute provider. Provider selection is made from the plan-known typed -wrappers, not from a string discriminator or runtime fallback. The selected -provider receives the resolved common runner settings and returns only the -provider-specific resources, environment variables, IAM fragments, and -outputs required by the orchestration provider. - -Webhook queues, Lambda functions, schedules, and retry behavior remain owned -by the webhook orchestration provider. EC2 instances, Lambda MicroVM capacity, -image publication, and provider-specific bootstrap behavior remain owned by -their compute providers. `runner-config` connects these capabilities but does -not absorb either provider's implementation. - -For Lambda MicroVM runners, the image is an immutable runtime artifact. The -runner configuration and its sensitive, short-lived bootstrap value are -published through the runner-config SSM contract and retrieved when the -MicroVM starts. Tenant-specific runner configuration, registration tokens, and -JIT payloads must not be baked into the image or its Terraform configuration. -The image therefore supplies the runner and lifecycle-hook runtime, while the -selected compute provider supplies the lane-specific SSM path and execution -permissions. - ```mermaid flowchart TD Multi["multi-runner: translate and resolve"] --> Config["runner-config: compose one runner config"] From cbd1b1de21f793e580d1c96115bc43f3ec48e6e3 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 9 Sep 2026 10:49:33 +0200 Subject: [PATCH 5/6] fix(ministack): configure microvm connector fixture --- tests/ministack/run-example.sh | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index 73e5866ea0..cbd3ae8891 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -12,6 +12,7 @@ export AWS_EC2_METADATA_DISABLED="${AWS_EC2_METADATA_DISABLED:-true}" action="${1:-}" example="${2:-}" tfvars_file="${3:-${MINISTACK_TFVARS_FILE:-}}" +microvm_foundation_default_tfvars=false case "$example" in base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation | microvm) @@ -41,6 +42,9 @@ example_root="$source_root/examples/$example" if [ "$use_tfvars" = true ]; then if [ -z "$tfvars_file" ]; then tfvars_file="$script_dir/$example.tfvars" + if [ "$example" = microvm-foundation ]; then + microvm_foundation_default_tfvars=true + fi fi case "$tfvars_file" in @@ -61,6 +65,7 @@ ami_created_ids="" ssm_created_names="" s3_created_buckets="" override_created_paths="" +tfvars_created_paths="" lambda_zip_paths=" $source_root/lambdas/functions/ami-housekeeper/ami-housekeeper.zip $source_root/lambdas/functions/control-plane/runners.zip @@ -74,6 +79,10 @@ cleanup() { rm -f "$override_file" done + for fixture_file in $tfvars_created_paths; do + rm -f "$fixture_file" + done + for name in $ssm_created_names; do ministack_aws ssm delete-parameter --name "$name" >/dev/null 2>&1 || true done @@ -176,6 +185,48 @@ $bucket" --body "$file" >/dev/null } +create_microvm_foundation_fixture() { + vpc_id=$(ministack_aws ec2 describe-vpcs \ + --filters Name=is-default,Values=true \ + --query 'Vpcs[0].VpcId' \ + --output text) + subnet_id=$(ministack_aws ec2 describe-subnets \ + --filters "Name=vpc-id,Values=$vpc_id" "Name=state,Values=available" \ + --query 'Subnets[0].SubnetId' \ + --output text) + + case "$vpc_id" in + vpc-[0-9a-f]*) ;; + *) + echo "MiniStack default VPC fixture was not found." >&2 + exit 70 + ;; + esac + + case "$subnet_id" in + subnet-[0-9a-f]*) ;; + *) + echo "MiniStack default subnet fixture was not found." >&2 + exit 70 + ;; + esac + + fixture_tfvars=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-foundation.XXXXXX") + printf '%s\n' \ + "aws_region = \"$AWS_DEFAULT_REGION\"" \ + '' \ + 'network_connectors = {' \ + ' ministack = {' \ + ' name = "ministack"' \ + " vpc_id = \"$vpc_id\"" \ + " subnet_ids = [\"$subnet_id\"]" \ + ' }' \ + '}' > "$fixture_tfvars" + tfvars_created_paths="$tfvars_created_paths +$fixture_tfvars" + tfvars_file="$fixture_tfvars" +} + create_ami_override() { override_file="$example_root/zz_ministack_ami_override.tf" printf '%s\n' \ @@ -344,6 +395,10 @@ create_ministack_fixtures() { wait_for_ministack + if [ "$microvm_foundation_default_tfvars" = true ]; then + create_microvm_foundation_fixture + fi + lambda_fixture_dir=$(mktemp -d "${TMPDIR:-/tmp}/terraform-aws-github-runner-ministack-lambda.XXXXXX") printf '%s\n' 'exports.handler = async () => ({ statusCode: 200, body: "ministack" });' > "$lambda_fixture_dir/index.js" (CDPATH='' cd -- "$lambda_fixture_dir" && zip -q ministack-lambda.zip index.js) From 0f18fd7de35c5ae896ec5eb4976b85d1a73fed84 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 9 Sep 2026 10:56:11 +0200 Subject: [PATCH 6/6] ci(ministack): remove matrix parallelism limit --- .github/workflows/ministack.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 0ae55b3048..b8a287e895 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -45,7 +45,6 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false - max-parallel: 4 matrix: example: - base