diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 80af872e42..2e7c69d235 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -70,9 +70,11 @@ jobs: - prebuilt - default - ephemeral + - microvm + - microvm-foundation + - migration-test - multi-runner - multi-runner-v2 - - migration-test - termination-watcher services: ministack: diff --git a/.github/workflows/packer-build.yml b/.github/workflows/packer-build.yml index 8dcff4efb6..729b1fc52e 100644 --- a/.github/workflows/packer-build.yml +++ b/.github/workflows/packer-build.yml @@ -26,12 +26,6 @@ jobs: runs-on: ubuntu-latest container: 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"] - defaults: - run: - working-directory: images/${{ matrix.image }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 @@ -42,9 +36,29 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: packer init - run: packer init . - - name: check packer formatting - run: packer fmt -recursive -check=true . - - name: packer validate - run: packer validate -evaluate-datasources . + + - name: Verify images + run: | + set -- "${GITHUB_WORKSPACE}"/images/* + found_image=false + + while [ "$#" -gt 0 ]; do + image_dir="$1" + shift + [ -d "${image_dir}" ] || continue + + found_image=true + image="${image_dir##*/}" + + echo "::group::Verifying ${image}" + cd "${image_dir}" + packer init . + packer fmt -recursive -check=true . + packer validate -evaluate-datasources . + echo "::endgroup::" + done + + [ "${found_image}" = true ] || { + echo "No image directories found under ${GITHUB_WORKSPACE}/images" + exit 1 + } diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 744727c3bc..cb36c9bbcf 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -50,6 +50,8 @@ env: prebuilt ephemeral termination-watcher + microvm + microvm-foundation multi-runner multi-runner-v2 external-managed-ssm-secrets diff --git a/docs/examples/index.md b/docs/examples/index.md index f0558966bd..b7bdf60811 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -11,3 +11,5 @@ Examples are located in the [examples](https://github.com/github-aws-runners/ter - _[Termination watcher](termination-watcher.md)_: Example usages of termination watcher. - _[Dedicated Mac Hosts](dedicated-mac-hosts.md)_: Example usage of setting up dedicated hosts for macOS runners. - _[Externally managed SSM secrets](external-managed-ssm-secrets.md)_: Example usage of externally managed SSM secrets for the GitHub App credentials. +- _[MicroVM foundation](microvm-foundation.md)_: Example usage of the regional Lambda MicroVM image-build and Network Connector prerequisites. +- _[Lambda MicroVM](microvm.md)_: Example usage of Linux ARM64 ephemeral runners backed by Lambda MicroVM images. diff --git a/docs/examples/microvm-foundation.md b/docs/examples/microvm-foundation.md new file mode 100644 index 0000000000..b92a148a9c --- /dev/null +++ b/docs/examples/microvm-foundation.md @@ -0,0 +1,3 @@ +# Lambda MicroVM foundation + +--8<-- "examples/microvm-foundation/README.md" diff --git a/docs/examples/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/docs/modules/public/microvm-foundation.md b/docs/modules/public/microvm-foundation.md new file mode 100644 index 0000000000..17129c131e --- /dev/null +++ b/docs/modules/public/microvm-foundation.md @@ -0,0 +1,3 @@ +# Lambda MicroVM regional foundation + +--8<-- "modules/microvm-foundation/README.md" diff --git a/examples/microvm-foundation/.terraform.lock.hcl b/examples/microvm-foundation/.terraform.lock.hcl new file mode 100644 index 0000000000..78cf3de9bd --- /dev/null +++ b/examples/microvm-foundation/.terraform.lock.hcl @@ -0,0 +1,49 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 6.61.0" + hashes = [ + "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=", + "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=", + "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611", + "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d", + "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a", + "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1", + "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d", + "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c", + "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb", + "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9", + "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd", + "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb", + "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925", + "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04", + "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54", + "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.14.1" + constraints = ">= 0.13.0" + hashes = [ + "h1:GJig5pIwiKDsiF73KLs7vWvDs76/x6DeNSxKrfqlA40=", + "h1:r93SxP++6gUlwCHDQ5OkRmcU8B0yv6ZA9nF0Dh6NJmA=", + "zh:0837ca5b057e5cff94dff7de2fcccafb4abaa33c45de193fe2853e684818a267", + "zh:15a122f72d9e0f34fc5384cc7ec089319641fee5c319748a3aa02fc42f459969", + "zh:342fb83093a280ea7ee0654feae1f5867c62eb8eebc1ab46f9a7ab0b4c878a62", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:99f169834d3370b8341381c6a9c7a8b01fb26027531faa38e6fb49cc23916f68", + "zh:9f482917c7a28cf2436578be7aa9f04f8c811aba8b5949e0223ea987a2757a91", + "zh:ac6b5b8732826f2d1129a8a4a038ac7a7a9ca7b77d2a4608e5703be1a1e2bff0", + "zh:c54782a27d58ce04f6696c6fc0b2cf1e2fba6bed239fb520521a7bce7d7193cb", + "zh:c8d0ddc8f575ecb44f025d54edbfe118e26397fe328a67be62325766f31eb6e7", + "zh:d043b96f204edd2353bf6b2a34e645ffdee2e9634d9bb747331320444810a538", + "zh:e32c288501ca9a6c9d22b52e839dd391fc7083d54ee6b8dc296ce0e6bd3e57ef", + "zh:e47fcc7bb4e9ab5cc522c3b06e4fa9c0bf94b84be8210bc6b1655c44acb2addc", + "zh:f61bf218322bcbe0bd2d56bba738e7fa485e9b54244e13aa12de741b37d450c0", + ] +} diff --git a/examples/microvm-foundation/.terraform.lock.hcl.tofu b/examples/microvm-foundation/.terraform.lock.hcl.tofu new file mode 100644 index 0000000000..045ca37e02 --- /dev/null +++ b/examples/microvm-foundation/.terraform.lock.hcl.tofu @@ -0,0 +1,76 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 6.61.0" + hashes = [ + "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", + "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", + "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", + "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", + "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", + "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", + "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", + "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", + "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", + "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", + "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", + "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", + "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", + "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", + "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", + "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", + "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", + "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", + "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", + "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", + "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", + "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", + "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", + "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", + "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", + "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", + "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", + "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", + "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", + "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", + ] +} + +provider "registry.opentofu.org/hashicorp/time" { + version = "0.14.1" + constraints = ">= 0.13.0" + hashes = [ + "h1:+anTsiSl8j75hcu7gKWF2ZlKS/qZDk4Ll0Oq2mVoArU=", + "h1:BZtorvSdYDM6pFE8nz7yUVVR2Pe1i0MxZFyBnaKlguY=", + "h1:Do/MjWRafefFS6RumnUVbIDH9MyMLuwQXs0kEc4Evrs=", + "h1:RoJeKHJjlqMikWdptWEWOBfBv4YxXf8KtZcg6oS8OWE=", + "h1:Ssb164oIHIO9VWGljof/xqbxbnPmrT5jyJ+WegAj2+k=", + "h1:WGgegEyoMb7nzXr09OvAxaSJls6honSdJiXfNaPTSkw=", + "h1:jfH6FAhiYd3hKB29s8cxk9PUKGdEN0fPB3zQ0IE5pdQ=", + "h1:lVTdvsa16YmLYJOmGq6ryESpdeWhLyo7Y4MAPPh/gIc=", + "h1:nD1nfDyZxI1PgTNT5Zs9G+R9PIiS25xQw9y7tuOD2G0=", + "h1:nPu3DGOZfwDier2k8DjNq2ZK3GQDzHYLghIUjb4/KJc=", + "h1:o7oRgk39V8okQzrI9DX4AKMTZrIqo6oBj1zQ3tMZJiI=", + "h1:oJSgnSkg9lXMISdrA+pXkV8FhgLPk+mAzbFE209jar0=", + "h1:oT/ffb2Uy19qRCLu9QSbuAguWwJI9rwW1j6RPjluqRY=", + "h1:vWWQpPBXFR9AVZM1o22/pHjNQjRa/IWSVL1mZgMqgLk=", + "h1:yTRqKp4efJyAq7bPZjFGcnR1KUDUl2/dBMzILxn5Z+c=", + "zh:032ea0f53759a5ade64286ad8a403956bb390860429de3647c6652701c2fff8b", + "zh:204581f170c50a579357b1a067f407b890adfc0404952cb922fefe2aba7655a8", + "zh:331119864191614a81ce9e8d1ec3ea6fe13da0bea6130f9e1dfd94e3b16ddaaa", + "zh:373a8b1b227a92b5e5fee611fef03df7aea82f51c3b1a62b33ac29a1b0ee927f", + "zh:46796e7616d511fa264a367ee447f6d0de64e8145f315a12271f4ec5c183f044", + "zh:5e3199e6dcc9bb99868764339c35aa169903baa4e150490da2980b2620cdfdbc", + "zh:5f95ee94a83a13e6b1e26d3d9f0297ed1036387d7a8adbab90b4ef990b6a1331", + "zh:68e1f75602423236d947d9464d62c4cc0a6312ff2206b9306067faf03bed7011", + "zh:72ee59f4f859abe6288b59eaacf9a838b8490a132c1c17f393dd8355401704df", + "zh:993775eeb0f0b4c898e305dead3cd6cd732b48c1c0fab20c2e4431ef6b678626", + "zh:a7b38fea85a1edea7f9afa33e9585970219a788d0300ca095e80b19eec39b291", + "zh:b7a8ef0dbee5b76295b1a11d88452ad2798e8d2598925af193884ce405093497", + "zh:d3ff8770f4b7cc4dccd4b1b7b40c933b468b7e07a05c704b7ea4c1673c74bf9c", + "zh:e59043c6f98aa986956a79c47f1d6bc150d409bbd9c88c9c42d41713bc539f04", + "zh:e6c2ae3bdccf6a2e3f106b61895cf7f125684204c1406b29f74fa408671f81cc", + ] +} diff --git a/examples/microvm-foundation/README.md b/examples/microvm-foundation/README.md new file mode 100644 index 0000000000..7417f3c70e --- /dev/null +++ b/examples/microvm-foundation/README.md @@ -0,0 +1,77 @@ +# MicroVM foundation example + +This example creates the regional dependencies required by the Lambda MicroVM +image build and runner runtime using the reusable module in this repository. + +Set `aws_region` and `artifact_bucket_name` in `terraform.tfvars` (copy +`terraform.tfvars.example`). The example creates its VPC and private subnets +through the reusable `base` example and wires them into the Network Connector. + +```bash +terraform init +terraform apply +terraform output +``` + +Apply this foundation before building an image with the direct Packer commands +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` +- `connector_arns.ministack` -> `MICROVM_EGRESS_NETWORK_CONNECTOR_ARN` +- `usage_policy_arn` -> attach to the control-plane role used by the runner example + +The foundation module owns regional storage, build IAM, Network Connectors, +and the reusable runtime policy. It does not publish an image or create the +runner control plane; those steps remain explicit and can be performed after +the foundation is available. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [aws](#requirement\_aws) | >= 6.61 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [base](#module\_base) | ../base | n/a | +| [microvm\_foundation](#module\_microvm\_foundation) | ../../modules/microvm-foundation | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [artifact\_bucket\_name](#input\_artifact\_bucket\_name) | Name for the regional MicroVM build-artifact bucket. | `string` | n/a | yes | +| [artifact\_retention\_days](#input\_artifact\_retention\_days) | Number of days to retain current and noncurrent build artifacts. | `number` | `30` | no | +| [aws\_profile](#input\_aws\_profile) | Optional local AWS CLI profile. Leave null when credentials are provided by the environment or role. | `string` | `null` | no | +| [aws\_region](#input\_aws\_region) | AWS region in which to create the MicroVM foundation. | `string` | `"eu-west-1"` | no | +| [build\_policy\_name\_prefix](#input\_build\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM build policy. | `string` | `"gha-microvm-build-policy-"` | no | +| [build\_role\_name\_prefix](#input\_build\_role\_name\_prefix) | Name prefix for the Lambda MicroVM build role. | `string` | `"gha-microvm-build-"` | no | +| [ecr\_repository\_arns](#input\_ecr\_repository\_arns) | Optional private ECR repository ARNs used by the image build. | `set(string)` | `[]` | no | +| [image\_name\_prefix](#input\_image\_name\_prefix) | Reserved Lambda MicroVM image-name namespace used by the runtime policy. | `string` | `"gha-ubuntu-arm64"` | no | +| [network\_connector\_operator\_role\_name\_prefix](#input\_network\_connector\_operator\_role\_name\_prefix) | Name prefix for the Lambda Network Connector operator role. | `string` | `"gha-microvm-network-operator-"` | no | +| [tags](#input\_tags) | Additional tags applied by the foundation module. | `map(string)` |
{
"Component": "microvm-foundation"
}
| no | +| [usage\_policy\_name\_prefix](#input\_usage\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM runtime usage policy. | `string` | `"gha-microvm-runtime-usage-policy-"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [artifact\_bucket\_name](#output\_artifact\_bucket\_name) | S3 bucket to pass to the MicroVM image build. | +| [artifact\_prefix](#output\_artifact\_prefix) | S3 prefix used for MicroVM build artifacts. | +| [build\_role\_arn](#output\_build\_role\_arn) | Lambda build role ARN to pass to the image build. | +| [connector\_arns](#output\_connector\_arns) | Regional Network Connector ARNs keyed by configuration name. | +| [usage\_policy\_arn](#output\_usage\_policy\_arn) | Unattached runtime usage policy for the runner control-plane role. | + diff --git a/examples/microvm-foundation/main.tf b/examples/microvm-foundation/main.tf new file mode 100644 index 0000000000..fd19d191ef --- /dev/null +++ b/examples/microvm-foundation/main.tf @@ -0,0 +1,35 @@ +locals { + environment = "microvm-foundation" + aws_region = var.aws_region + + network_connectors = { + ministack = { + name = "ministack" + vpc_id = module.base.vpc.vpc_id + subnet_ids = module.base.vpc.private_subnets + } + } +} + +module "base" { + source = "../base" + + prefix = local.environment + aws_region = local.aws_region +} + +module "microvm_foundation" { + source = "../../modules/microvm-foundation" + + aws_region = local.aws_region + tags = var.tags + build_policy_name_prefix = var.build_policy_name_prefix + build_role_name_prefix = var.build_role_name_prefix + network_connector_operator_role_name_prefix = var.network_connector_operator_role_name_prefix + usage_policy_name_prefix = var.usage_policy_name_prefix + artifact_bucket_name = var.artifact_bucket_name + artifact_retention_days = var.artifact_retention_days + image_name_prefix = var.image_name_prefix + ecr_repository_arns = var.ecr_repository_arns + network_connectors = local.network_connectors +} diff --git a/examples/microvm-foundation/outputs.tf b/examples/microvm-foundation/outputs.tf new file mode 100644 index 0000000000..709d43f933 --- /dev/null +++ b/examples/microvm-foundation/outputs.tf @@ -0,0 +1,24 @@ +output "artifact_bucket_name" { + description = "S3 bucket to pass to the MicroVM image build." + value = module.microvm_foundation.artifact_bucket_name +} + +output "artifact_prefix" { + description = "S3 prefix used for MicroVM build artifacts." + value = module.microvm_foundation.artifact_prefix +} + +output "build_role_arn" { + description = "Lambda build role ARN to pass to the image build." + value = module.microvm_foundation.build_role_arn +} + +output "connector_arns" { + description = "Regional Network Connector ARNs keyed by configuration name." + value = module.microvm_foundation.connector_arns +} + +output "usage_policy_arn" { + description = "Unattached runtime usage policy for the runner control-plane role." + value = module.microvm_foundation.usage_policy_arn +} diff --git a/examples/microvm-foundation/providers.tf b/examples/microvm-foundation/providers.tf new file mode 100644 index 0000000000..9e8a8a7627 --- /dev/null +++ b/examples/microvm-foundation/providers.tf @@ -0,0 +1,4 @@ +provider "aws" { + region = var.aws_region + profile = var.aws_profile +} diff --git a/examples/microvm-foundation/terraform.tfvars.example b/examples/microvm-foundation/terraform.tfvars.example new file mode 100644 index 0000000000..9e3d766661 --- /dev/null +++ b/examples/microvm-foundation/terraform.tfvars.example @@ -0,0 +1,8 @@ +aws_region = "eu-west-1" +artifact_bucket_name = "microvm-foundation-artifacts-eu-west-1" + +# Add the private ECR repository that contains the regional Ubuntu base image +# when the image build pulls from ECR. +# ecr_repository_arns = [ +# "arn:aws:ecr:eu-west-1:123456789012:repository/actions-runner-base-image", +# ] diff --git a/examples/microvm-foundation/variables.tf b/examples/microvm-foundation/variables.tf new file mode 100644 index 0000000000..9163e2e81f --- /dev/null +++ b/examples/microvm-foundation/variables.tf @@ -0,0 +1,67 @@ +variable "aws_profile" { + type = string + description = "Optional local AWS CLI profile. Leave null when credentials are provided by the environment or role." + default = null + nullable = true +} + +variable "aws_region" { + type = string + description = "AWS region in which to create the MicroVM foundation." + default = "eu-west-1" +} + +variable "tags" { + type = map(string) + description = "Additional tags applied by the foundation module." + default = { + Component = "microvm-foundation" + } +} + +variable "build_policy_name_prefix" { + type = string + description = "Name prefix for the Lambda MicroVM build policy." + default = "gha-microvm-build-policy-" +} + +variable "usage_policy_name_prefix" { + type = string + description = "Name prefix for the Lambda MicroVM runtime usage policy." + default = "gha-microvm-runtime-usage-policy-" +} + +variable "build_role_name_prefix" { + type = string + description = "Name prefix for the Lambda MicroVM build role." + default = "gha-microvm-build-" +} + +variable "network_connector_operator_role_name_prefix" { + type = string + description = "Name prefix for the Lambda Network Connector operator role." + default = "gha-microvm-network-operator-" +} + +variable "artifact_bucket_name" { + type = string + description = "Name for the regional MicroVM build-artifact bucket." +} + +variable "artifact_retention_days" { + type = number + description = "Number of days to retain current and noncurrent build artifacts." + default = 30 +} + +variable "image_name_prefix" { + type = string + description = "Reserved Lambda MicroVM image-name namespace used by the runtime policy." + default = "gha-ubuntu-arm64" +} + +variable "ecr_repository_arns" { + type = set(string) + description = "Optional private ECR repository ARNs used by the image build." + default = [] +} diff --git a/examples/microvm-foundation/versions.tf b/examples/microvm-foundation/versions.tf new file mode 100644 index 0000000000..23dd3947d6 --- /dev/null +++ b/examples/microvm-foundation/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.61" + } + } +} diff --git a/examples/microvm/.terraform.lock.hcl b/examples/microvm/.terraform.lock.hcl new file mode 100644 index 0000000000..e46d40b514 --- /dev/null +++ b/examples/microvm/.terraform.lock.hcl @@ -0,0 +1,71 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" + hashes = [ + "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=", + "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=", + "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611", + "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d", + "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a", + "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1", + "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d", + "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c", + "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb", + "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9", + "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd", + "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb", + "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925", + "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04", + "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54", + "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.3.1" + constraints = "~> 3.0, ~> 3.2" + hashes = [ + "h1:TuxJq10DVnRP7c5HBZPyyvQGcckNVfijyU1eXEu5e4M=", + "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=", + "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44", + "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451", + "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05", + "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3", + "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7", + "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1", + "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be", + "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891", + "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5", + "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610", + "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/examples/microvm/.terraform.lock.hcl.tofu b/examples/microvm/.terraform.lock.hcl.tofu new file mode 100644 index 0000000000..7aa235531d --- /dev/null +++ b/examples/microvm/.terraform.lock.hcl.tofu @@ -0,0 +1,113 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" + hashes = [ + "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", + "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", + "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", + "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", + "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", + "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", + "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", + "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", + "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", + "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", + "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", + "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", + "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", + "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", + "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", + "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", + "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", + "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", + "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", + "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", + "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", + "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", + "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", + "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", + "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", + "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", + "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", + "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", + "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", + "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", + ] +} + +provider "registry.opentofu.org/hashicorp/null" { + version = "3.3.1" + constraints = "~> 3.0, ~> 3.2" + hashes = [ + "h1:2wld81FnmHW0WVgy081sIfokCr2+NuatS8yjeLEet7Y=", + "h1:AClQjJ6X22V4qcRgcYSxiXCMmp2pz0G8WVQC7wAx66o=", + "h1:AY3XQbuviNd2X5VhHYEbhNta1m/CG3JD2BKFKhCt1Y4=", + "h1:CUOZUd7H11lsU+4tISlnYIiP5BqnX8IDwFCVqfLJyAg=", + "h1:JIfV0nA/pLWnIFGscvTfuavQCn2NeHxJBeb6UUg/joA=", + "h1:RejAh+nyCwqDGExGln2Kb4Ro5LyHak0eJe0P9g8CHPc=", + "h1:SHOuTZjYymsmy4asuRq6NC3yW+zdVZOOt4f5nrb+EPM=", + "h1:WwPat/gT4gO8GvvKNdSkkXWVD65JppLJfqKOt9HhOqQ=", + "h1:Z3hXVLrOyaRiiLmmL5UCOdcRMguwjN1x5TYNdmBDgls=", + "h1:dd78Ad5HdfPzPts7A9qIxfitXhAriV/qza38fr2ukjk=", + "h1:dyVb++KwDdybzLTE6bf7GZiVQ31iWsgKPWmhTQ8G42k=", + "h1:gD8ZH6WWe+5gg5+y8SpLWGPUDzSxcQ3HKP8IDM/wW3I=", + "h1:juXCww0zRQKFTDZoKqYR0+Sn1lu99oeL6pr0Jh6LWx0=", + "h1:kFAySmtsshyNV7IhIrEdASzVcvwy68eeZCVC66P7yNk=", + "h1:nS5azDopRisB2NInwDx3Hrfg2FdVt8Gw0gTQzC0rd70=", + "zh:164eb061d84e01759f391265865fb31828083d0a06b25f7af7e094cbdb18c799", + "zh:1bb9b669a82b52c0cba2860c71e9ee6699ef302f28cb8ed06f572d39bc6c7c4f", + "zh:1ea9b31a8f29302122c1e8d673693f3ac270336dae560af803cd1117265a469a", + "zh:238bd463cb0154fb935dc331da40c0a9cbe5db9cee615ae5f35ccad5eed7dc41", + "zh:30ef2b7384cf7e20f33fe75754b54cf669d59816f3ad4fc73bfb2b26fb6735e9", + "zh:35b5cded16e4b57c207d03ee0979b14baf486fa520e6edb7a2eecf18f1b85471", + "zh:3dc840d13a50cd215c7540573f27e2b61f739ba90aee5b7c3846079aa0ab5534", + "zh:3f9309a18db608f975d5691fcb47a6e14d77199156a52e9c39dcafe3737f2b07", + "zh:44263a219f7dbd1848b545d080110b4f7d0495e77b71cd3c7a0b5ec52a09accb", + "zh:4dec54aa5f445eeea035bbd4839bcded5e47ecd07cba0e70c5a09e9272cb592f", + "zh:5e8fb319d7c6d6c4566a18b9d0c91580b4901a96acd7fdc476bfc79f074368e2", + "zh:b0e8b6d41834b57fcfbb5ca00da52ccb757e1a95b6a2d546c0dae8bfbeca1cdf", + "zh:bbde4c3a1dcc1718027a61a4cdf661619d17af1b58df1038fe27bcf43c3dc29b", + "zh:c4140fff9f692baf29236557f706f9515f93229413438527d764023a82301da3", + "zh:f8e9d83184e4bbeb97c6f0d569833007c48ba5a7ff334def201df4991d03a962", + ] +} + +provider "registry.opentofu.org/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:8EQU5KSxezcjo/phRSe69rDOI0lk4pSaggj7FsskYp8=", + "h1:Lw9im2VBBJQ3RyAbHPQ0rcvcmmcZWm3x+kIOpN+Tv9s=", + "h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=", + "h1:YXaVd4p6qXPPVaxIBaIDNXmBwT02ZqDn0qD+tYpw8sA=", + "h1:cOpc03fphEt/G9Rfc4jLL/fW0D7tgvlXqiDKPF4vuww=", + "h1:g09RR7T1xWkeGrZwWvWMT9ncJrFGr1k3CBD585UmO7w=", + "h1:gGDdPPibmw2EWROx+sh1RGLjR5+nPwZyrf6/N9jXfeM=", + "h1:haE7/nXCOhXKP4oXeEnER3t5CaVQWqujz4nBnpeTUv4=", + "h1:ieSVpfZS2lKuMr05ph0QsOVpCzg7uk3cgKBaXR+Ikug=", + "h1:ig2s1IS9IzehorRjvVAnKIsUUj8fkgyxct1L/kswcc4=", + "h1:j3lS+ZEERFnoab8t1ppDrScGVP/cgWbzlCrEYKTCXYw=", + "h1:lxezrKmOiQIySHAM+os8qLVq7hqufDr8h3Hpzvsk+78=", + "h1:lzRqBJAG+NETxHbEZUJ/YP3RMEjZBinTX7VmgH3lw60=", + "h1:tdSNWK5ApqUsgbdYieyeYLTu6nIZUV3hR1oFqUfAuGo=", + "h1:xedet8yH/zI2CfdxsGlK0nlFWc/Bp61yrWsEa3fHB8g=", + "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", + "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", + "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", + "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", + "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", + "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", + "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", + "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", + "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", + "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", + "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", + "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", + "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", + "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", + "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", + ] +} diff --git a/examples/microvm/README.md b/examples/microvm/README.md new file mode 100644 index 0000000000..72bcf69260 --- /dev/null +++ b/examples/microvm/README.md @@ -0,0 +1,107 @@ +# 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 | +| [random](#requirement\_random) | ~> 3.0 | + +## Providers + +| Name | Version | +|------|---------| +| [random](#provider\_random) | 3.9.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [base](#module\_base) | ../base | n/a | +| [runners](#module\_runners) | ../../modules/multi-runner | n/a | + +## Resources + +| Name | Type | +|------|------| +| [random_id.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_region](#input\_aws\_region) | AWS Region 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) | GitHub for API usages. |
object({
id = string
key_base64 = 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..4c51dd52e1 --- /dev/null +++ b/examples/microvm/main.tf @@ -0,0 +1,109 @@ +locals { + environment = coalesce(var.environment, "microvm") + aws_region = var.aws_region +} + +module "base" { + source = "../base" + + prefix = local.environment + aws_region = local.aws_region +} + +resource "random_id" "random" { + byte_length = 20 +} + +module "runners" { + source = "../../modules/multi-runner" + + aws_region = local.aws_region + prefix = local.environment + + experimental_features = ["multi-runner-v2"] + + global_config_github = { + app = { + key_base64 = var.github_app.key_base64 + id = var.github_app.id + webhook_secret = random_id.random.hex + } + } + + global_config_lambda = { + artifact = { + s3 = { + bucket = var.lambda_artifact_bucket + } + } + } + + 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 + } + } + } + } + } + } + + global_config_ssm = { + paths = { + root = "/github-action-runners/${local.environment}" + } + } + + 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] + } + } + } + + 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..4f8eaa3bc9 --- /dev/null +++ b/examples/microvm/variables.tf @@ -0,0 +1,72 @@ +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 = "GitHub for API usages." + + type = object({ + id = string + key_base64 = string + }) +} + + +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..8ace4cfd44 --- /dev/null +++ b/examples/microvm/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } + 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/mkdocs.yaml b/mkdocs.yaml index 4026d30d6e..81b4992a9e 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -69,6 +69,7 @@ nav: - AMI Housekeeper: modules/public/ami-housekeeper.md - Lambda Downloader: modules/public/download-lambda.md - Setup IAM permissions: modules/public/setup-iam-permissions.md + - MicroVM foundation: modules/public/microvm-foundation.md - Submodules (internal): - Runners: modules/internal/runners.md - Syncer: modules/internal/runner-binaries-syncer.md diff --git a/modules/microvm-foundation/README.md b/modules/microvm-foundation/README.md new file mode 100644 index 0000000000..d87227c89f --- /dev/null +++ b/modules/microvm-foundation/README.md @@ -0,0 +1,134 @@ +# Lambda MicroVM Regional Foundation + +This module creates the regional AWS prerequisites for building and running +Lambda MicroVM GitHub Actions runners and is intended to be deployed once per +AWS Region. + +It manages: + +- A private, encrypted, versioned S3 bucket for content-addressed image build artifacts. +- A Lambda-trusted build role with scoped S3, CloudWatch Logs, and optional ECR pull access. +- Dedicated no-ingress security groups and native Lambda Network Connector resources for each configured VPC/subnet set. +- A Lambda-trusted Network Connector operator role and propagation barrier. +- An unattached runtime usage policy for the reserved image namespace and connector inventory. + +The module does not create MicroVM images, runner execution roles, or the +runner control plane. Attach `usage_policy_arn` to the control-plane role that +owns the runtime launch operations. The caller must also grant the Terraform +identity `iam:PassRole` for the operator role with +`iam:PassedToService=lambda.amazonaws.com`. + +The module deliberately does not configure an AWS provider. Configure the +provider in the root module or example so credentials and account selection +remain caller-owned. + +```hcl +provider "aws" { + region = "eu-west-1" +} + +module "microvm_foundation" { + source = "../../modules/microvm-foundation" + + aws_region = "eu-west-1" + tags = { Environment = "example" } + build_policy_name_prefix = "github-actions-runner-microvm-build-policy-" + build_role_name_prefix = "github-actions-runner-microvm-build-" + network_connector_operator_role_name_prefix = "github-actions-runner-microvm-network-operator-" + usage_policy_name_prefix = "github-actions-runner-microvm-runtime-usage-policy-" + + image_name_prefix = "github-actions-runner-ubuntu-arm64" + + network_connectors = { + cicd = { + name = "github-actions-runner-egress" + vpc_id = "vpc-0123456789abcdef0" + subnet_ids = ["subnet-0123456789abcdef0"] + } + } +} +``` + +The companion `examples/microvm-foundation` directory is a complete setup +example. Apply it before following the direct Packer build instructions in +`images/microvm-ubuntu/README.md` or using the `examples/microvm` runner example. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [aws](#requirement\_aws) | >= 6.61 | +| [time](#requirement\_time) | >= 0.13 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | >= 6.61 | +| [time](#provider\_time) | >= 0.13 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_iam_policy.build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_policy.usage](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_role.build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.operator](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.operator](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambdacore_network_connector.connector](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambdacore_network_connector) | resource | +| [aws_s3_bucket.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource | +| [aws_s3_bucket_lifecycle_configuration.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_lifecycle_configuration) | resource | +| [aws_s3_bucket_ownership_controls.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_ownership_controls) | resource | +| [aws_s3_bucket_policy.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_policy) | resource | +| [aws_s3_bucket_public_access_block.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_public_access_block) | resource | +| [aws_s3_bucket_server_side_encryption_configuration.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_server_side_encryption_configuration) | resource | +| [aws_s3_bucket_versioning.artifacts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_versioning) | resource | +| [aws_security_group.connector](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_vpc_security_group_egress_rule.ipv4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_vpc_security_group_egress_rule.ipv6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [time_sleep.operator_role_propagation](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.artifact_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_service_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.network_connector_assume_operator_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.usage](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source | +| [aws_subnet.selected](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [artifact\_bucket\_name](#input\_artifact\_bucket\_name) | Name for the regional MicroVM build-artifact bucket. | `string` | n/a | yes | +| [artifact\_retention\_days](#input\_artifact\_retention\_days) | Number of days to retain current and noncurrent MicroVM build artifacts. | `number` | `30` | no | +| [aws\_region](#input\_aws\_region) | AWS region in which to create the Lambda MicroVM prerequisites. | `string` | n/a | yes | +| [build\_policy\_name\_prefix](#input\_build\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM build policy. | `string` | n/a | yes | +| [build\_role\_name\_prefix](#input\_build\_role\_name\_prefix) | Name prefix for the Lambda MicroVM build role. | `string` | n/a | yes | +| [ecr\_repository\_arns](#input\_ecr\_repository\_arns) | Optional regional ECR repository ARNs from which MicroVM image builds can pull runner base images. | `set(string)` | `[]` | no | +| [image\_name\_prefix](#input\_image\_name\_prefix) | IAM namespace prefix reserved for externally published Lambda MicroVM image names. This module does not create or enumerate images. | `string` | n/a | yes | +| [network\_connector\_operator\_role\_name\_prefix](#input\_network\_connector\_operator\_role\_name\_prefix) | Name prefix for the Lambda Network Connector operator role. | `string` | n/a | yes | +| [network\_connectors](#input\_network\_connectors) | Regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity. |
map(object({
name = string
vpc_id = string
subnet_ids = list(string)
network_protocol = optional(string, "IPv4")
}))
| n/a | yes | +| [tags](#input\_tags) | A map of module-specific tags to apply to resources. | `map(string)` | n/a | yes | +| [usage\_policy\_name\_prefix](#input\_usage\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM runtime usage policy. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [artifact\_bucket\_arn](#output\_artifact\_bucket\_arn) | ARN of the regional S3 bucket used for Lambda MicroVM build artifacts. | +| [artifact\_bucket\_name](#output\_artifact\_bucket\_name) | Name of the regional S3 bucket used for Lambda MicroVM build artifacts. | +| [artifact\_prefix](#output\_artifact\_prefix) | Bucket prefix to which the MicroVM image publisher uploads content-addressed build artifacts. | +| [build\_role\_arn](#output\_build\_role\_arn) | ARN of the Lambda-trusted role used during MicroVM image builds. | +| [connector\_arns](#output\_connector\_arns) | Map of connector key to the ARN of each Lambda Network Connector. | +| [security\_group\_ids](#output\_security\_group\_ids) | Map of connector key to its dedicated no-ingress security group ID. | +| [usage\_policy\_arn](#output\_usage\_policy\_arn) | ARN of the reusable regional policy for operating MicroVM images in the reserved namespace and passing their Network Connectors. | + diff --git a/modules/microvm-foundation/build.tf b/modules/microvm-foundation/build.tf new file mode 100644 index 0000000000..160f3d9399 --- /dev/null +++ b/modules/microvm-foundation/build.tf @@ -0,0 +1,84 @@ +# Lambda assumes this role while building an image snapshot. +data "aws_iam_policy_document" "build" { + statement { + sid = "ReadRegionalBuildArtifact" + effect = "Allow" + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.artifacts.arn}/${local.artifact_prefix}/*"] + } + + statement { + sid = "CreateMicrovmBuildLogGroups" + effect = "Allow" + actions = ["logs:CreateLogGroup"] + resources = [local.log_group_arn_pattern] + } + + statement { + sid = "WriteMicrovmBuildLogs" + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = [local.log_stream_arn_pattern] + } + + dynamic "statement" { + for_each = length(var.ecr_repository_arns) > 0 ? [true] : [] + content { + sid = "AuthorizePrivateEcrPull" + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + } + + dynamic "statement" { + for_each = length(var.ecr_repository_arns) > 0 ? [true] : [] + content { + sid = "PullPrivateEcrImage" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + ] + resources = var.ecr_repository_arns + } + } +} + +resource "aws_iam_policy" "build" { + name_prefix = var.build_policy_name_prefix + description = "Regional permissions used by Lambda while building MicroVM images." + policy = data.aws_iam_policy_document.build.json + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "build" { + role = aws_iam_role.build.name + policy_arn = aws_iam_policy.build.arn +} + +data "aws_iam_policy_document" "lambda_service_assume_role" { + statement { + sid = "LambdaMicrovmService" + effect = "Allow" + actions = [ + "sts:AssumeRole", + "sts:TagSession", + ] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "build" { + name_prefix = var.build_role_name_prefix + assume_role_policy = data.aws_iam_policy_document.lambda_service_assume_role.json + tags = var.tags +} diff --git a/modules/microvm-foundation/data.tf b/modules/microvm-foundation/data.tf new file mode 100644 index 0000000000..f1a2621728 --- /dev/null +++ b/modules/microvm-foundation/data.tf @@ -0,0 +1,31 @@ +data "aws_caller_identity" "current" {} + +data "aws_partition" "current" {} + +data "aws_subnet" "selected" { + for_each = local.network_connector_subnets + id = each.value.subnet_id +} + +locals { + artifact_prefix = "lambda-microvms" + + image_arn_pattern = "arn:${data.aws_partition.current.partition}:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:microvm-image:${var.image_name_prefix}-*" + log_group_arn_pattern = "arn:${data.aws_partition.current.partition}:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/microvms/${var.image_name_prefix}-*" + log_stream_arn_pattern = "${local.log_group_arn_pattern}:log-stream:*" + + network_connector_subnets = merge({}, [ + for connector_key, connector in var.network_connectors : { + for subnet_index, subnet_id in connector.subnet_ids : + "${connector_key}/${subnet_index}" => { + connector_key = connector_key + subnet_id = subnet_id + } + } + ]...) + + connector_arns = { + for connector_key, connector in aws_lambdacore_network_connector.connector : + connector_key => connector.arn + } +} diff --git a/modules/microvm-foundation/network_connector.tf b/modules/microvm-foundation/network_connector.tf new file mode 100644 index 0000000000..5a444e3489 --- /dev/null +++ b/modules/microvm-foundation/network_connector.tf @@ -0,0 +1,31 @@ +resource "aws_lambdacore_network_connector" "connector" { + for_each = var.network_connectors + + name = each.value.name + operator_role = aws_iam_role.operator.arn + + configuration { + vpc_egress_configuration { + associated_compute_resource_types = ["MicroVm"] + network_protocol = each.value.network_protocol + security_group_ids = [aws_security_group.connector[each.key].id] + subnet_ids = sort(tolist(each.value.subnet_ids)) + } + } + + lifecycle { + precondition { + condition = alltrue([ + for subnet_index, subnet_id in each.value.subnet_ids : + data.aws_subnet.selected["${each.key}/${subnet_index}"].vpc_id == each.value.vpc_id + ]) + error_message = "Every subnet in network_connectors[${each.key}] must belong to its configured vpc_id." + } + } + + depends_on = [ + time_sleep.operator_role_propagation, + aws_vpc_security_group_egress_rule.ipv4, + aws_vpc_security_group_egress_rule.ipv6, + ] +} diff --git a/modules/microvm-foundation/network_connector_operator.tf b/modules/microvm-foundation/network_connector_operator.tf new file mode 100644 index 0000000000..fc7d03fd74 --- /dev/null +++ b/modules/microvm-foundation/network_connector_operator.tf @@ -0,0 +1,41 @@ +data "aws_iam_policy_document" "network_connector_assume_operator_role" { + statement { + sid = "LambdaNetworkConnectorService" + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["network-connectors.lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "operator" { + name_prefix = var.network_connector_operator_role_name_prefix + assume_role_policy = data.aws_iam_policy_document.network_connector_assume_operator_role.json + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "operator" { + role = aws_iam_role.operator.name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AWSLambdaNetworkConnectorOperatorPolicy" +} + +# IAM reports role and policy writes before they are consistently available to +# Lambda. Wait before allowing the native Network Connector resource to create +# any connector. +resource "time_sleep" "operator_role_propagation" { + depends_on = [aws_iam_role_policy_attachment.operator] + + create_duration = "30s" + + triggers = { + operator_role_unique_id = aws_iam_role.operator.unique_id + operator_trust_policy_sha256 = sha256(aws_iam_role.operator.assume_role_policy) + } + + lifecycle { + replace_triggered_by = [aws_iam_role_policy_attachment.operator] + } +} diff --git a/modules/microvm-foundation/network_connector_security_group.tf b/modules/microvm-foundation/network_connector_security_group.tf new file mode 100644 index 0000000000..7d88e37d2d --- /dev/null +++ b/modules/microvm-foundation/network_connector_security_group.tf @@ -0,0 +1,40 @@ +# A connector gets its own no-ingress security group. Route tables and NACLs on +# the caller-selected subnets determine actual destinations reachable through +# the IPv4 or dual-stack egress rules. +resource "aws_security_group" "connector" { + #checkov:skip=CKV2_AWS_5:The security group is consumed by the Lambda Network Connector rather than by a Terraform-native ENI resource. + for_each = var.network_connectors + + name = "microvm-${each.value.name}-${var.aws_region}" + description = "Outbound egress for the ${each.value.name} Lambda MicroVM Network Connector" + vpc_id = each.value.vpc_id + + tags = merge(var.tags, { + Name = "microvm-${each.value.name}-${var.aws_region}" + }) +} + +resource "aws_vpc_security_group_egress_rule" "ipv4" { + #checkov:skip=CKV_AWS_382:The connector requires outbound access; subnet routes and NACLs provide the network destination boundary. + for_each = var.network_connectors + + security_group_id = aws_security_group.connector[each.key].id + description = "Lambda MicroVM connector IPv4 egress" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + tags = var.tags +} + +resource "aws_vpc_security_group_egress_rule" "ipv6" { + #checkov:skip=CKV_AWS_382:Dual-stack connector egress is intentional; subnet routes and NACLs provide the network destination boundary. + for_each = { + for connector_key, connector in var.network_connectors : + connector_key => connector if connector.network_protocol == "DualStack" + } + + security_group_id = aws_security_group.connector[each.key].id + description = "Lambda MicroVM connector IPv6 egress" + ip_protocol = "-1" + cidr_ipv6 = "::/0" + tags = var.tags +} diff --git a/modules/microvm-foundation/outputs.tf b/modules/microvm-foundation/outputs.tf new file mode 100644 index 0000000000..9fd52ab4bf --- /dev/null +++ b/modules/microvm-foundation/outputs.tf @@ -0,0 +1,34 @@ +output "artifact_bucket_name" { + description = "Name of the regional S3 bucket used for Lambda MicroVM build artifacts." + value = aws_s3_bucket.artifacts.id +} + +output "artifact_bucket_arn" { + description = "ARN of the regional S3 bucket used for Lambda MicroVM build artifacts." + value = aws_s3_bucket.artifacts.arn +} + +output "artifact_prefix" { + description = "Bucket prefix to which the MicroVM image publisher uploads content-addressed build artifacts." + value = local.artifact_prefix +} + +output "build_role_arn" { + description = "ARN of the Lambda-trusted role used during MicroVM image builds." + value = aws_iam_role.build.arn +} + +output "usage_policy_arn" { + description = "ARN of the reusable regional policy for operating MicroVM images in the reserved namespace and passing their Network Connectors." + value = aws_iam_policy.usage.arn +} + +output "connector_arns" { + description = "Map of connector key to the ARN of each Lambda Network Connector." + value = local.connector_arns +} + +output "security_group_ids" { + description = "Map of connector key to its dedicated no-ingress security group ID." + value = { for connector_key, security_group in aws_security_group.connector : connector_key => security_group.id } +} diff --git a/modules/microvm-foundation/storage.tf b/modules/microvm-foundation/storage.tf new file mode 100644 index 0000000000..73ff52e172 --- /dev/null +++ b/modules/microvm-foundation/storage.tf @@ -0,0 +1,103 @@ +# Lambda MicroVM image source artifacts must be stored in an S3 bucket in the +# same region as the image. A separate helper deployment owns the bucket in each +# supported region. +resource "aws_s3_bucket" "artifacts" { + #checkov:skip=CKV_AWS_145:SSE-S3 protects ephemeral content-addressed build inputs; this helper has no CMK artifact contract. + #checkov:skip=CKV_AWS_144:Lambda MicroVM builds require same-region artifacts, so this regional bucket intentionally has no cross-region replication. + #checkov:skip=CKV_AWS_18:CloudTrail records control-plane access and the bucket contains short-lived build inputs; separate S3 access logging is not required. + #checkov:skip=CKV2_AWS_62:The publisher uploads artifacts synchronously and no event-driven consumer requires S3 notifications. + bucket = var.artifact_bucket_name + tags = var.tags +} + +resource "aws_s3_bucket_ownership_controls" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_versioning" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_public_access_block" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + skip_destroy = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + id = "expire-microvm-build-artifacts" + + # The bucket is dedicated to MicroVM build artifacts, so lifecycle cleanup + # applies to every object, including abandoned uploads outside the expected + # publisher prefix. + filter {} + + expiration { + days = var.artifact_retention_days + } + + noncurrent_version_expiration { + noncurrent_days = var.artifact_retention_days + } + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + + status = "Enabled" + } + + depends_on = [aws_s3_bucket_versioning.artifacts] +} + +data "aws_iam_policy_document" "artifact_bucket" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + actions = ["s3:*"] + resources = [ + aws_s3_bucket.artifacts.arn, + "${aws_s3_bucket.artifacts.arn}/*", + ] + + principals { + type = "*" + identifiers = ["*"] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + policy = data.aws_iam_policy_document.artifact_bucket.json +} diff --git a/modules/microvm-foundation/usage_policy.tf b/modules/microvm-foundation/usage_policy.tf new file mode 100644 index 0000000000..8abe004bee --- /dev/null +++ b/modules/microvm-foundation/usage_policy.tf @@ -0,0 +1,58 @@ +# Consumer modules can attach this policy to a control-plane role they own. +# This helper deliberately leaves the managed policy unattached. +data "aws_iam_policy_document" "usage" { + statement { + sid = "UseConfiguredMicrovmImages" + effect = "Allow" + actions = [ + "lambda:CreateMicrovmAuthToken", + "lambda:GetMicrovm", + "lambda:GetMicrovmImage", + "lambda:GetMicrovmImageVersion", + "lambda:ListMicrovmImageVersions", + "lambda:ResumeMicrovm", + "lambda:RunMicrovm", + "lambda:SuspendMicrovm", + "lambda:TerminateMicrovm", + ] + resources = [local.image_arn_pattern] + } + + #checkov:skip=CKV_AWS_111:ListMicrovms and ListMicrovmImages do not support resource-level permissions. + #checkov:skip=CKV_AWS_356:Lambda MicroVM account-level list actions require Resource '*'. + statement { + sid = "DiscoverMicrovmRuntimeState" + effect = "Allow" + actions = [ + "lambda:ListMicrovmImages", + "lambda:ListMicrovms", + ] + resources = ["*"] + } + + statement { + sid = "ReadConfiguredNetworkConnectors" + effect = "Allow" + actions = ["lambda:GetNetworkConnector"] + resources = values(local.connector_arns) + } + + #checkov:skip=CKV_AWS_111:PassNetworkConnector and ListNetworkConnectors do not support resource-level permissions. + #checkov:skip=CKV_AWS_356:Lambda requires Resource '*' for PassNetworkConnector and the account-level list operation. + statement { + sid = "PassAndDiscoverNetworkConnectors" + effect = "Allow" + actions = [ + "lambda:ListNetworkConnectors", + "lambda:PassNetworkConnector", + ] + resources = ["*"] + } +} + +resource "aws_iam_policy" "usage" { + name_prefix = var.usage_policy_name_prefix + description = "Permissions to discover and operate configured Lambda MicroVM images and to read and pass their regional Network Connectors." + policy = data.aws_iam_policy_document.usage.json + tags = var.tags +} diff --git a/modules/microvm-foundation/variables.tf b/modules/microvm-foundation/variables.tf new file mode 100644 index 0000000000..c21e6068c0 --- /dev/null +++ b/modules/microvm-foundation/variables.tf @@ -0,0 +1,154 @@ +variable "aws_region" { + type = string + description = "AWS region in which to create the Lambda MicroVM prerequisites." +} + +variable "tags" { + type = map(string) + description = "A map of module-specific tags to apply to resources." +} + +variable "build_policy_name_prefix" { + type = string + description = "Name prefix for the Lambda MicroVM build policy." + + validation { + condition = length(var.build_policy_name_prefix) >= 1 && length(var.build_policy_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.build_policy_name_prefix)) + error_message = "build_policy_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + } +} + +variable "usage_policy_name_prefix" { + type = string + description = "Name prefix for the Lambda MicroVM runtime usage policy." + + validation { + condition = length(var.usage_policy_name_prefix) >= 1 && length(var.usage_policy_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.usage_policy_name_prefix)) + error_message = "usage_policy_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + } +} + +variable "build_role_name_prefix" { + type = string + description = "Name prefix for the Lambda MicroVM build role." + + validation { + condition = length(var.build_role_name_prefix) >= 1 && length(var.build_role_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.build_role_name_prefix)) + error_message = "build_role_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + } +} + +variable "network_connector_operator_role_name_prefix" { + type = string + description = "Name prefix for the Lambda Network Connector operator role." + + validation { + condition = length(var.network_connector_operator_role_name_prefix) >= 1 && length(var.network_connector_operator_role_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.network_connector_operator_role_name_prefix)) + error_message = "network_connector_operator_role_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + } +} + +variable "artifact_bucket_name" { + type = string + description = "Name for the regional MicroVM build-artifact bucket." +} + +variable "artifact_retention_days" { + type = number + description = "Number of days to retain current and noncurrent MicroVM build artifacts." + default = 30 + + validation { + condition = var.artifact_retention_days >= 1 && var.artifact_retention_days <= 3650 + error_message = "artifact_retention_days must be between 1 and 3650." + } +} + +variable "image_name_prefix" { + type = string + description = "IAM namespace prefix reserved for externally published Lambda MicroVM image names. This module does not create or enumerate images." + + validation { + condition = ( + length(var.image_name_prefix) >= 1 + && length(var.image_name_prefix) <= 62 + && can(regex("^[a-zA-Z0-9-_]+$", var.image_name_prefix)) + ) + error_message = "image_name_prefix must be a 1 to 62 character IAM namespace containing only letters, numbers, hyphens, or underscores; the publisher validates each complete image name." + } +} + +variable "ecr_repository_arns" { + type = set(string) + description = "Optional regional ECR repository ARNs from which MicroVM image builds can pull runner base images." + default = [] +} + +variable "network_connectors" { + type = map(object({ + name = string + vpc_id = string + subnet_ids = list(string) + network_protocol = optional(string, "IPv4") + })) + description = "Regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity." + + validation { + condition = length(var.network_connectors) > 0 + error_message = "network_connectors must contain at least one connector." + } + + validation { + condition = alltrue([ + for connector in values(var.network_connectors) : ( + length(distinct(connector.subnet_ids)) == length(connector.subnet_ids) + ) + ]) + error_message = "Each network connector must contain distinct subnet IDs." + } + + validation { + condition = alltrue([ + for connector in values(var.network_connectors) : ( + length(connector.name) >= 1 + && length(connector.name) <= 64 + && can(regex("^[a-zA-Z0-9_-]+$", connector.name)) + ) + ]) + error_message = "Each network connector name must contain only letters, numbers, hyphens, or underscores and be at most 64 characters." + } + + validation { + condition = ( + length(distinct([for connector in values(var.network_connectors) : connector.name])) == length(var.network_connectors) + ) + error_message = "Each network connector name must be unique within the region." + } + + validation { + condition = alltrue([ + for connector in values(var.network_connectors) : can(regex("^vpc-[0-9a-f]+$", connector.vpc_id)) + ]) + error_message = "Each network connector vpc_id must be a valid VPC ID." + } + + validation { + condition = alltrue([ + for connector in values(var.network_connectors) : ( + length(connector.subnet_ids) >= 1 + && length(connector.subnet_ids) <= 16 + && alltrue([ + for subnet_id in connector.subnet_ids : can(regex("^subnet-[0-9a-f]+$", subnet_id)) + ]) + ) + ]) + error_message = "Each network connector must contain 1 to 16 valid subnet IDs." + } + + validation { + condition = alltrue([ + for connector in values(var.network_connectors) : contains(["IPv4", "DualStack"], connector.network_protocol) + ]) + error_message = "Each network connector network_protocol must be IPv4 or DualStack." + } +} diff --git a/modules/microvm-foundation/versions.tf b/modules/microvm-foundation/versions.tf new file mode 100644 index 0000000000..40dc4af0e8 --- /dev/null +++ b/modules/microvm-foundation/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.5.6" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.61" + } + time = { + source = "hashicorp/time" + version = ">= 0.13" + } + } +} diff --git a/tests/ministack/README.md b/tests/ministack/README.md index 3e6cdc82fa..0f1a1965d7 100644 --- a/tests/ministack/README.md +++ b/tests/ministack/README.md @@ -5,7 +5,9 @@ The MiniStack workflow runs the `base`, `prebuilt`, `default`, `ephemeral`, with Terraform 1.5.6 and the latest Terraform release, and with OpenTofu 1.11 and the latest OpenTofu 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 +in this directory. The `microvm-foundation` example uses the reusable `base` +example module to create its VPC and private subnets, then wires those outputs +into the MicroVM Network Connector. The `termination-watcher` example has no input variables and uses the configuration checked into the example itself. No override files, setup module, or Terraform fixture configuration is checked in. The helper creates and removes a temporary AMI override for `default` and diff --git a/tests/ministack/microvm-foundation.tfvars b/tests/ministack/microvm-foundation.tfvars new file mode 100644 index 0000000000..253a9ffaba --- /dev/null +++ b/tests/ministack/microvm-foundation.tfvars @@ -0,0 +1,3 @@ +aws_region = "eu-west-1" + +artifact_bucket_name = "ministack-microvm-artifacts-eu-west-1" diff --git a/tests/ministack/microvm.tfvars b/tests/ministack/microvm.tfvars new file mode 100644 index 0000000000..c777b7228a --- /dev/null +++ b/tests/ministack/microvm.tfvars @@ -0,0 +1,12 @@ + +aws_region = "eu-west-1" +environment = "microvm-ministack" + +github_app = { + id = "your-github-app-id" + key_base64 = "your-github-app-key-base64" +} + +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 961b3a8cab..bb52c7689a 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -21,9 +21,8 @@ case "$iac_binary" in exit 64 ;; esac - case "$example" in - base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2) + base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation | microvm) use_tfvars=true ;; migration-test) @@ -33,7 +32,7 @@ case "$example" in use_tfvars=false ;; *) - echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, migration-test, termination-watcher" >&2 + echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, microvm-foundation, microvm, migration-test, termination-watcher" >&2 exit 64 ;; esac @@ -41,7 +40,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|migration-test|termination-watcher} [TFVARS_FILE]" >&2 + echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|microvm-foundation|microvm|migration-test|termination-watcher} [TFVARS_FILE]" >&2 exit 64 ;; esac @@ -80,15 +79,17 @@ if [ "$use_tfvars" = true ]; then tfvars_file="$script_dir/$example.tfvars" fi - case "$tfvars_file" in - /*) ;; - *) tfvars_file="$PWD/$tfvars_file" ;; - esac + if [ -n "$tfvars_file" ]; then + case "$tfvars_file" in + /*) ;; + *) tfvars_file="$PWD/$tfvars_file" ;; + esac - if [ ! -f "$tfvars_file" ]; then - echo "Terraform variables file not found: $tfvars_file" >&2 - echo "Pass it as the third argument or set MINISTACK_TFVARS_FILE." >&2 - exit 66 + if [ ! -f "$tfvars_file" ]; then + echo "Terraform variables file not found: $tfvars_file" >&2 + echo "Pass it as the third argument or set MINISTACK_TFVARS_FILE." >&2 + exit 66 + fi fi fi @@ -96,6 +97,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 @@ -118,6 +120,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 @@ -225,6 +233,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' \ @@ -325,6 +352,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 }