From 352c14a2ab5bf002b56859e2350a52217ac0b445 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 11 Aug 2026 13:20:16 +0200 Subject: [PATCH 1/5] feat(runners): support multiple compute providers --- docs/configurations/deployments/platform.md | 14 +- docs/reference/module-catalog.md | 4 +- docs/reference/module-dependencies.md | 2 +- docs/reference/module-layout.md | 12 +- .../terragrunt/_global_settings/tenant.hcl | 2 +- .../vpcs/main/tenants/acme/config.yml | 1 + .../main/tenants/acme/runner_settings.hcl | 102 +++++--- .../terragrunt/environments/terragrunt.hcl | 5 +- .../platform/_global_settings/tenant.hcl | 2 +- examples/templates/platform/tenant/config.yml | 19 ++ .../platform/tenant/runner_settings.hcl | 102 +++++--- modules/platform/ec2_deployment/README.md | 48 +++- modules/platform/ec2_deployment/ami.tf | 2 +- .../ec2_update_runner_ssm_ami.tf | 19 +- .../ec2_deployment/ec2_update_runner_tags.tf | 1 + modules/platform/ec2_deployment/main.tf | 165 ++----------- modules/platform/ec2_deployment/outputs.tf | 58 ++++- modules/platform/ec2_deployment/roles.tf | 6 +- .../ec2_deployment/runner_configs_v2.tf | 203 ++++++++++++++++ .../scripts/download_lambdas.sh | 9 +- .../ec2_deployment/tests/behavior.tftest.hcl | 221 ++++++++++++++++++ .../tests/interface_contract.tftest.hcl | 148 ++++++++---- .../tests/source_inventory.tftest.hcl | 6 + modules/platform/ec2_deployment/variables.tf | 152 ++++++++---- modules/platform/forge_runners/README.md | 16 +- modules/platform/forge_runners/ec2_runners.tf | 5 +- .../forge_runners/forge_trust_validator.tf | 2 +- .../forge_runners/github_actions_job_log.tf | 2 +- modules/platform/forge_runners/locals.tf | 8 + modules/platform/forge_runners/outputs.tf | 11 +- .../forge_runners/redrive_deadletter.tf | 3 +- .../tests/interface_contract.tftest.hcl | 154 ++++++------ .../tests/source_inventory.tftest.hcl | 10 + modules/platform/forge_runners/variables.tf | 200 ++++++++++------ 34 files changed, 1229 insertions(+), 485 deletions(-) create mode 100644 modules/platform/ec2_deployment/runner_configs_v2.tf create mode 100644 modules/platform/ec2_deployment/tests/behavior.tftest.hcl diff --git a/docs/configurations/deployments/platform.md b/docs/configurations/deployments/platform.md index f4ab04940..4234f2f2b 100644 --- a/docs/configurations/deployments/platform.md +++ b/docs/configurations/deployments/platform.md @@ -1,8 +1,8 @@ # Platform Deployment This is the main Forge runtime path. It deploys tenant runner control planes -through `modules/platform/forge_runners`, which can create EC2 runners and ARC -runner scale sets. +through `modules/platform/forge_runners`, which can create EC2 or Lambda +MicroVM runners and ARC runner scale sets. Deploy root: @@ -24,7 +24,7 @@ ______________________________________________________________________ | `environments/prod/_environment_wide_settings/_environment.yml` | AWS account, default region, remote state, and naming suffixes. | | `environments/prod/regions/eu-west-1/_region_wide_settings/_region.hcl` | Region and short region alias used in labels and names. | | `environments/prod/regions/eu-west-1/vpcs/main/_vpc_wide_settings/_vpc.yml` | VPC ID, Lambda subnets, runner subnets, VPC alias, and cluster name. | -| `environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml` | Tenant GitHub App, IAM, EC2 runner specs, and optional ARC specs. | +| `environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml` | Tenant GitHub App, IAM, compute-provider runner specs, and ARC specs. | | `release_versions.yml` | Module source, version, and `module_path` metadata. | The example uses `prod`, `eu-west-1`, `main`, and `acme` as copyable defaults. @@ -72,7 +72,7 @@ wide applies. ______________________________________________________________________ -## EC2 Only vs ARC +## Compute Providers and ARC For EC2-only tenants: @@ -81,6 +81,12 @@ For EC2-only tenants: - Skip `examples/deployments/infra`. - Skip Kubernetes and EKS integrations. +For Lambda MicroVM lanes, add entries under `microvm_runner_specs`; the +Terragrunt translation merges them with EC2 lanes into the same provider-aware +`runner_specs` map and prefixes generated lane keys with `microvm-`. Until +upstream publishes the provider-aware Lambda release, draft validation requires +matching PR-built ZIPs through `USE_CACHE` and `CACHE_PATH`. + For ARC tenants: - Deploy or provide an EKS cluster first. diff --git a/docs/reference/module-catalog.md b/docs/reference/module-catalog.md index c16a6391f..a7b3c103b 100644 --- a/docs/reference/module-catalog.md +++ b/docs/reference/module-catalog.md @@ -8,8 +8,8 @@ deployment and what can be skipped. | Module | Role | Required? | Example root | Validation | | ------------------------------------------------------------ | ------------------------------------------------- | ----------------------- | --------------------------------------------------- | ------------------------------------------------- | -| `modules/platform/forge_runners` | Tenant-facing runner platform entrypoint. | Yes | `examples/deployments/platform` | Tenant plan/apply plus EC2 or ARC smoke job. | -| `modules/platform/ec2_deployment` | EC2 ephemeral runner lane. | Through `forge_runners` | `examples/deployments/platform` | EC2 smoke job launches, registers, and cleans up. | +| `modules/platform/forge_runners` | Tenant-facing runner platform entrypoint. | Yes | `examples/deployments/platform` | Tenant plan/apply plus compute or ARC smoke job. | +| `modules/platform/ec2_deployment` | EC2 and MicroVM compute-provider runner lanes. | Through `forge_runners` | `examples/deployments/platform` | Selected provider launches and cleans up runners. | | `modules/platform/arc_deployment` | Tenant ARC runner lane wrapper. | Only for ARC | `examples/deployments/platform` | ARC smoke job and scale-set reconciliation. | | `modules/platform/arc` | ARC controller and scale-set foundation. | Only for ARC | `examples/deployments/infra` and platform ARC specs | `kubectl`, Helm, and ARC resource checks. | | `modules/platform/forge_runners/forge_trust_validator` | Tenant trust validation support. | Through `forge_runners` | `examples/deployments/platform` | Tenant role trust and workflow role assumption. | diff --git a/docs/reference/module-dependencies.md b/docs/reference/module-dependencies.md index c0ad49eb3..11c0073c6 100644 --- a/docs/reference/module-dependencies.md +++ b/docs/reference/module-dependencies.md @@ -35,7 +35,7 @@ ______________________________________________________________________ | Module | Depends on | Notes | | --------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | | `modules/platform/forge_runners` | Tenant GitHub App values, SSM key parameter, VPC/subnets, runner specs | Main entry point for tenant runners. | -| `modules/platform/ec2_deployment` | Called by `forge_runners`; runner AMIs; GitHub App | EC2 ephemeral runners. | +| `modules/platform/ec2_deployment` | Called by `forge_runners`; provider configuration; GitHub App | EC2 or Lambda MicroVM ephemeral runners. | | `modules/platform/arc_deployment` | Called by `forge_runners`; EKS; Kubernetes/Helm access | ARC scale sets. | | `modules/platform/arc` | EKS cluster and Kubernetes providers | Lower-level ARC controller and scale-set wrapper. | | `modules/infra/eks` | VPC, private subnets, AWS access | Needed only when Forge owns the ARC EKS foundation. | diff --git a/docs/reference/module-layout.md b/docs/reference/module-layout.md index 941421d21..f7cf4258e 100644 --- a/docs/reference/module-layout.md +++ b/docs/reference/module-layout.md @@ -25,12 +25,12 @@ ______________________________________________________________________ ## Platform Modules -| Module | Purpose | Directly call it? | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| `modules/platform/forge_runners` | Tenant runner entry point. Wires EC2 and ARC runner specs, GitHub App behavior, trust validation, job logs, relay options, and service catalog data. | Yes. This is the normal tenant-facing module. | -| `modules/platform/ec2_deployment` | EC2 runner lane backed by `terraform-aws-github-runner`. | Usually through `forge_runners`. | -| `modules/platform/arc_deployment` | Tenant ARC runner lane wrapper. | Usually through `forge_runners`. | -| `modules/platform/arc` | Lower-level ARC controller and scale-set Helm wrapper. | No, unless building a custom platform wrapper. | +| Module | Purpose | Directly call it? | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `modules/platform/forge_runners` | Tenant runner entry point. Wires compute-provider and ARC runner specs, GitHub App behavior, trust validation, job logs, relay options, and service catalog data. | Yes. This is the normal tenant-facing module. | +| `modules/platform/ec2_deployment` | EC2 and Lambda MicroVM runner lanes backed by `terraform-aws-github-runner`; the historical path is retained. | Usually through `forge_runners`. | +| `modules/platform/arc_deployment` | Tenant ARC runner lane wrapper. | Usually through `forge_runners`. | +| `modules/platform/arc` | Lower-level ARC controller and scale-set Helm wrapper. | No, unless building a custom platform wrapper. | ______________________________________________________________________ diff --git a/examples/deployments/platform/terragrunt/_global_settings/tenant.hcl b/examples/deployments/platform/terragrunt/_global_settings/tenant.hcl index e2a45980b..94dadbc6a 100644 --- a/examples/deployments/platform/terragrunt/_global_settings/tenant.hcl +++ b/examples/deployments/platform/terragrunt/_global_settings/tenant.hcl @@ -64,7 +64,7 @@ inputs = { lambda_vpc_id = local.config.locals.lambda_vpc_id subnet_ids = local.config.locals.subnet_ids vpc_id = local.config.locals.vpc_id - runner_specs = local.config.locals.ec2_runner_specs + runner_specs = local.config.locals.runner_specs } arc_deployment_specs = { diff --git a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml index c1d69f6a6..b8bac016c 100644 --- a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml +++ b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml @@ -98,6 +98,7 @@ ec2_runner_specs: iops: 3000 throughput: 125 type: gp3 +microvm_runner_specs: {} arc_runner_specs: dependabot: runner_size: diff --git a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl index 448f429fb..8521a8892 100644 --- a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl +++ b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl @@ -63,12 +63,6 @@ locals { ec2_runner_specs = { for size, spec in local.config.ec2_runner_specs : size => { - ami_filter = { - name = [spec.ami_name], - state = ["available"], - } - ami_owners = [spec.ami_owner] - ami_kms_key_arn = spec.ami_kms_key_arn runner_os = spec.runner_os runner_architecture = spec.runner_architecture runner_labels = [ @@ -83,36 +77,92 @@ locals { "vpc:${local.vpc_alias}", "tnt:${local.tenant_name}", ] - enable_userdata = true enable_dynamic_labels = try(spec.enable_dynamic_labels, false) aws_dynamic_labels_policy = try(spec.aws_dynamic_labels_policy, null) lambda_event_source_mapping_batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) lambda_event_source_mapping_maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) redrive_build_queue = try(spec.redrive_build_queue, {}) runner_user = spec.runner_user - instance_target_capacity_type = "on-demand" min_run_time = 30 max_instances = spec.max_instances - instance_types = spec.instance_types - placement = try(spec.placement, null) - license_specifications = try(spec.license_specifications, null) - use_dedicated_host = try(spec.use_dedicated_host, false) - vpc_id = try(spec.vpc_id, null) - subnet_ids = try(spec.subnet_ids, null) - block_device_mappings = [{ - delete_on_termination = true - device_name = spec.volume.device_name - encrypted = true - iops = spec.volume.iops - kms_key_id = null - snapshot_id = null - throughput = spec.volume.throughput - volume_size = spec.volume.size - volume_type = spec.volume.type - }] - pool_config = spec.pool_config + pool_config = spec.pool_config + compute_provider = { + ec2 = { + ami_filter = { + name = [spec.ami_name] + state = ["available"] + } + ami_owners = [spec.ami_owner] + ami_kms_key_arn = trimspace(spec.ami_kms_key_arn) == "" ? null : spec.ami_kms_key_arn + enable_userdata = true + instance_target_capacity_type = "on-demand" + instance_types = spec.instance_types + placement = try(spec.placement, null) + license_specifications = try(spec.license_specifications, null) + use_dedicated_host = try(spec.use_dedicated_host, false) + vpc_id = try(spec.vpc_id, null) + subnet_ids = try(spec.subnet_ids, null) + scale_errors = try(spec.scale_errors, null) + block_device_mappings = [{ + delete_on_termination = true + device_name = spec.volume.device_name + encrypted = true + iops = spec.volume.iops + kms_key_id = null + snapshot_id = null + throughput = spec.volume.throughput + volume_size = spec.volume.size + volume_type = spec.volume.type + }] + } + } + } + } + + microvm_runner_specs = { + for name, spec in try(local.config.microvm_runner_specs, {}) : + "microvm-${name}" => { + runner_os = try(spec.runner_os, "linux") + runner_architecture = spec.runner_architecture + runner_labels = [ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ] + extra_labels = [ + "microvm", + "rgn:${local.region_alias}", + "vpc:${local.vpc_alias}", + "tnt:${local.tenant_name}", + ] + enable_dynamic_labels = try(spec.enable_dynamic_labels, false) + aws_dynamic_labels_policy = try(spec.aws_dynamic_labels_policy, null) + lambda_event_source_mapping_batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + lambda_event_source_mapping_maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + redrive_build_queue = try(spec.redrive_build_queue, {}) + runner_user = spec.runner_user + min_run_time = try(spec.min_run_time, 30) + max_instances = spec.max_instances + pool_config = try(spec.pool_config, []) + compute_provider = { + microvm = { + image_identifier = spec.image_identifier + image_version = try(spec.image_version, null) + egress_network_connectors = try(spec.egress_network_connectors, []) + idle_policy = try(spec.idle_policy, null) + logging = try(spec.logging, null) + run_hook_payload = try(spec.run_hook_payload, null) + maximum_duration_in_seconds = try(spec.maximum_duration_in_seconds, null) + environment_variables = try(spec.environment_variables, {}) + tags = try(spec.tags, {}) + iam = try(spec.iam, {}) + } + } } } + + runner_specs = merge(local.ec2_runner_specs, local.microvm_runner_specs) arc_cluster_name = local.config.arc_cluster_name migrate_arc_cluster = local.config.migrate_arc_cluster diff --git a/examples/deployments/platform/terragrunt/environments/terragrunt.hcl b/examples/deployments/platform/terragrunt/environments/terragrunt.hcl index 54b69831f..c0443ac42 100644 --- a/examples/deployments/platform/terragrunt/environments/terragrunt.hcl +++ b/examples/deployments/platform/terragrunt/environments/terragrunt.hcl @@ -1,5 +1,8 @@ locals { - github_runner_module_source = get_env("GITHUB_RUNNER_MODULE_SOURCE", "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=v7.8.0") + # Draft dependency on terraform-aws-github-runner PR #5260. Replace this + # commit with the first release that includes multi_runner_config_v2 and the + # EC2/MicroVM provider-aware Lambda artifacts. + github_runner_module_source = get_env("GITHUB_RUNNER_MODULE_SOURCE", "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=961c1208a3831d19af8c0cfb43a7ed8b2d81e34b") } generate "github_runner_source_override" { diff --git a/examples/templates/platform/_global_settings/tenant.hcl b/examples/templates/platform/_global_settings/tenant.hcl index eeb4e5df2..82279e3ae 100644 --- a/examples/templates/platform/_global_settings/tenant.hcl +++ b/examples/templates/platform/_global_settings/tenant.hcl @@ -59,7 +59,7 @@ inputs = { lambda_vpc_id = local.config.locals.lambda_vpc_id subnet_ids = local.config.locals.subnet_ids vpc_id = local.config.locals.vpc_id - runner_specs = local.config.locals.ec2_runner_specs + runner_specs = local.config.locals.runner_specs } arc_deployment_specs = { diff --git a/examples/templates/platform/tenant/config.yml b/examples/templates/platform/tenant/config.yml index 62b3e8ef7..62bc58154 100644 --- a/examples/templates/platform/tenant/config.yml +++ b/examples/templates/platform/tenant/config.yml @@ -59,6 +59,25 @@ ec2_runner_specs: iops: throughput: type: +microvm_runner_specs: + : + type: + runner_os: linux + runner_architecture: + runner_user: + max_instances: + image_identifier: + image_version: + egress_network_connectors: + - + idle_policy: + max_idle_duration_seconds: + suspended_duration_seconds: + auto_resume_enabled: + maximum_duration_in_seconds: + environment_variables: {} + tags: {} + pool_config: [] arc_runner_specs: : runner_size: diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index 448f429fb..8521a8892 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -63,12 +63,6 @@ locals { ec2_runner_specs = { for size, spec in local.config.ec2_runner_specs : size => { - ami_filter = { - name = [spec.ami_name], - state = ["available"], - } - ami_owners = [spec.ami_owner] - ami_kms_key_arn = spec.ami_kms_key_arn runner_os = spec.runner_os runner_architecture = spec.runner_architecture runner_labels = [ @@ -83,36 +77,92 @@ locals { "vpc:${local.vpc_alias}", "tnt:${local.tenant_name}", ] - enable_userdata = true enable_dynamic_labels = try(spec.enable_dynamic_labels, false) aws_dynamic_labels_policy = try(spec.aws_dynamic_labels_policy, null) lambda_event_source_mapping_batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) lambda_event_source_mapping_maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) redrive_build_queue = try(spec.redrive_build_queue, {}) runner_user = spec.runner_user - instance_target_capacity_type = "on-demand" min_run_time = 30 max_instances = spec.max_instances - instance_types = spec.instance_types - placement = try(spec.placement, null) - license_specifications = try(spec.license_specifications, null) - use_dedicated_host = try(spec.use_dedicated_host, false) - vpc_id = try(spec.vpc_id, null) - subnet_ids = try(spec.subnet_ids, null) - block_device_mappings = [{ - delete_on_termination = true - device_name = spec.volume.device_name - encrypted = true - iops = spec.volume.iops - kms_key_id = null - snapshot_id = null - throughput = spec.volume.throughput - volume_size = spec.volume.size - volume_type = spec.volume.type - }] - pool_config = spec.pool_config + pool_config = spec.pool_config + compute_provider = { + ec2 = { + ami_filter = { + name = [spec.ami_name] + state = ["available"] + } + ami_owners = [spec.ami_owner] + ami_kms_key_arn = trimspace(spec.ami_kms_key_arn) == "" ? null : spec.ami_kms_key_arn + enable_userdata = true + instance_target_capacity_type = "on-demand" + instance_types = spec.instance_types + placement = try(spec.placement, null) + license_specifications = try(spec.license_specifications, null) + use_dedicated_host = try(spec.use_dedicated_host, false) + vpc_id = try(spec.vpc_id, null) + subnet_ids = try(spec.subnet_ids, null) + scale_errors = try(spec.scale_errors, null) + block_device_mappings = [{ + delete_on_termination = true + device_name = spec.volume.device_name + encrypted = true + iops = spec.volume.iops + kms_key_id = null + snapshot_id = null + throughput = spec.volume.throughput + volume_size = spec.volume.size + volume_type = spec.volume.type + }] + } + } + } + } + + microvm_runner_specs = { + for name, spec in try(local.config.microvm_runner_specs, {}) : + "microvm-${name}" => { + runner_os = try(spec.runner_os, "linux") + runner_architecture = spec.runner_architecture + runner_labels = [ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ] + extra_labels = [ + "microvm", + "rgn:${local.region_alias}", + "vpc:${local.vpc_alias}", + "tnt:${local.tenant_name}", + ] + enable_dynamic_labels = try(spec.enable_dynamic_labels, false) + aws_dynamic_labels_policy = try(spec.aws_dynamic_labels_policy, null) + lambda_event_source_mapping_batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + lambda_event_source_mapping_maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + redrive_build_queue = try(spec.redrive_build_queue, {}) + runner_user = spec.runner_user + min_run_time = try(spec.min_run_time, 30) + max_instances = spec.max_instances + pool_config = try(spec.pool_config, []) + compute_provider = { + microvm = { + image_identifier = spec.image_identifier + image_version = try(spec.image_version, null) + egress_network_connectors = try(spec.egress_network_connectors, []) + idle_policy = try(spec.idle_policy, null) + logging = try(spec.logging, null) + run_hook_payload = try(spec.run_hook_payload, null) + maximum_duration_in_seconds = try(spec.maximum_duration_in_seconds, null) + environment_variables = try(spec.environment_variables, {}) + tags = try(spec.tags, {}) + iam = try(spec.iam, {}) + } + } } } + + runner_specs = merge(local.ec2_runner_specs, local.microvm_runner_specs) arc_cluster_name = local.config.arc_cluster_name migrate_arc_cluster = local.config.migrate_arc_cluster diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index 2c5a4e123..a369a1950 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -1,24 +1,42 @@ -# EC2 Runner Deployment +# Compute Runner Deployment -This module deploys Forge EC2 runner pools using the upstream `terraform-aws-github-runner` multi-runner module. +This module deploys Forge EC2 and Lambda MicroVM runner pools through the +upstream `terraform-aws-github-runner` multi-runner module. The historical +`ec2_deployment` module path is retained. ## Why This Module Exists -The EC2 lane gives a GitHub Actions job a full VM or dedicated host. Forge uses it for workloads that need VM-level control, custom AMIs, macOS/Windows, larger hardware, or stronger isolation than a normal pod can provide. +Provider-aware runner lanes let a tenant choose a full EC2 VM or a Lambda +MicroVM per label set while sharing the same webhook and runner control plane. +Forge keeps EC2 for workloads that need custom AMIs, macOS/Windows, dedicated +hosts, or larger hardware, and can use MicroVMs for Linux workloads supported +by the MicroVM image catalog. ## What It Manages - The upstream multi-runner control plane for webhook, scale-up, scale-down, and ephemeral runner registration. -- Per-runner-pool label matching, AMI selection, instance types, warm pool schedules, and capacity type. -- KMS key material, Lambda egress security group, runner tags policy, and logging hooks. -- Supporting modules that update runner tags and runner AMI SSM parameters. +- Per-lane label matching, provider selection, warm pool schedules, and capacity limits. +- EC2 AMI, instance type, storage, user data, tag, and logging-hook configuration. +- MicroVM image, network connector, idle policy, logging, runtime, and IAM configuration. +- Shared KMS key material and Lambda egress security group, plus EC2-only AMI and tag helpers. ## Operational Notes -- Every EC2 runner is ephemeral; the instance is expected to register for one job and then be reaped. +- This is a breaking input migration: every `runner_specs` entry must contain + exactly one non-null `compute_provider.ec2` or `compute_provider.microvm` + block. The legacy flat EC2 shape is not accepted. +- The upstream v2 path changes Terraform resource addresses from the v1 runner + modules to provider-oriented runner stacks; this module does not include an + in-place state migration. +- The upstream dependency is pinned to draft PR #5260. Until its provider-aware + Lambda artifacts are released, plans require matching PR-built ZIPs supplied + with `USE_CACHE` and `CACHE_PATH`. +- MicroVM lanes use the upstream module-managed execution role so Forge's + tenant-assumption, ECR, and global-lock policies apply to the runtime identity. +- Every compute runner is ephemeral and is expected to register for one job and then be reaped. - Label sets are the API contract with tenant workflows, so exact matching matters. -- Cold starts can take minutes; use warm pools only where latency justifies the idle cost. -- Subnet IP capacity and EC2 capacity errors are expected operational signals, not unusual exceptions. +- Cold starts vary by provider; use warm pools only where latency justifies the idle cost. +- Subnet IP capacity and provider capacity errors are expected operational signals, not unusual exceptions. ## Requirements @@ -34,7 +52,7 @@ The EC2 lane gives a GitHub Actions job a full VM or dedicated host. Forge uses | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.57.1 | +| [aws](#provider\_aws) | 6.58.0 | | [external](#provider\_external) | 2.4.0 | ## Modules @@ -43,7 +61,7 @@ The EC2 lane gives a GitHub Actions job a full VM or dedicated host. Forge uses | ---- | ------ | ------- | | [ec2\_update\_runner\_ssm\_ami](#module\_ec2\_update\_runner\_ssm\_ami) | ./ec2_update_runner_ssm_ami | n/a | | [ec2\_update\_runner\_tags](#module\_ec2\_update\_runner\_tags) | ./ec2_update_runner_tags | n/a | -| [runners](#module\_runners) | git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner | v7.10.1 | +| [runners](#module\_runners) | git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner | 961c1208a3831d19af8c0cfb43a7ed8b2d81e34b | ## Resources @@ -72,7 +90,7 @@ The EC2 lane gives a GitHub Actions job a full VM or dedicated host. Forge uses | ---- | ----------- | ---- | ------- | :------: | | [aws\_region](#input\_aws\_region) | Assuming single region for now. | `string` | n/a | yes | | [network\_configs](#input\_network\_configs) | n/a |
object({
vpc_id = string
subnet_ids = list(string)
lambda_vpc_id = string
lambda_subnet_ids = list(string)
})
| n/a | yes | -| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = string
ghes_org = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_group_name = string
scale_errors = optional(list(string), [])
runner_specs = map(object({
ami_filter = object({
name = list(string)
state = list(string)
})
ami_kms_key_arn = string
ami_owners = list(string)
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(any, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
instance_types = list(string)
license_specifications = optional(list(object({
license_configuration_arn = string
})), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
use_dedicated_host = optional(bool, false)
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
enable_userdata = bool
instance_target_capacity_type = string
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
block_device_mappings = list(object({
delete_on_termination = bool
device_name = string
encrypted = bool
iops = number
kms_key_id = string
snapshot_id = string
throughput = number
volume_size = number
volume_type = string
}))
}))
})
| n/a | yes | +| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = string
ghes_org = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_group_name = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = optional(object({
ami_filter = object({
name = list(string)
state = list(string)
})
ami_kms_key_arn = string
ami_owners = list(string)
instance_types = list(string)
license_specifications = optional(list(object({
license_configuration_arn = string
})), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
use_dedicated_host = optional(bool, false)
enable_userdata = bool
instance_target_capacity_type = string
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
scale_errors = optional(list(string), [])
block_device_mappings = list(object({
delete_on_termination = bool
device_name = string
encrypted = bool
iops = number
kms_key_id = string
snapshot_id = string
throughput = number
volume_initialization_rate = optional(number)
volume_size = number
volume_type = string
}))
}), null)
microvm = optional(object({
image_identifier = string
image_version = optional(string, null)
egress_network_connectors = optional(list(string), [])
idle_policy = optional(object({
max_idle_duration_seconds = number
suspended_duration_seconds = number
auto_resume_enabled = bool
}), null)
logging = optional(object({
cloud_watch = optional(object({
log_group = optional(string, null)
log_stream = optional(string, null)
}), null)
disabled = optional(bool, false)
}), null)
run_hook_payload = optional(string, null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
tags = optional(map(string), {})
iam = optional(object({
resource_arns = optional(list(string), ["*"])
actions = optional(object({
scale_up = optional(list(string), null)
scale_down = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policy_arns = optional(object({
scale_up = optional(string, null)
pool = optional(string, null)
}), {})
}), {})
}), null)
})
}))
})
| n/a | yes | | [tenant\_configs](#input\_tenant\_configs) | n/a |
object({
ecr_registries = list(string)
tags = map(string)
})
| n/a | yes | ## Outputs @@ -82,7 +100,13 @@ The EC2 lane gives a GitHub Actions job a full VM or dedicated host. Forge uses | [ec2\_runners\_ami\_name\_map](#output\_ec2\_runners\_ami\_name\_map) | Map of EC2 runner keys to the AMI names used for each runner. | | [ec2\_runners\_arn\_map](#output\_ec2\_runners\_arn\_map) | Map of EC2 runner keys to their IAM role ARNs. | | [ec2\_runners\_labels\_map](#output\_ec2\_runners\_labels\_map) | Map of EC2 runner keys to their base and extra GitHub labels. | +| [ec2\_runners\_map](#output\_ec2\_runners\_map) | Map of EC2 runner keys to their provider-specific resources. | | [event\_bus\_name](#output\_event\_bus\_name) | Name of the EventBridge event bus used by the webhook relay. | +| [microvm\_runners\_arn\_map](#output\_microvm\_runners\_arn\_map) | Map of MicroVM runner keys to their execution role ARNs. | +| [microvm\_runners\_labels\_map](#output\_microvm\_runners\_labels\_map) | Map of MicroVM runner keys to their base and extra GitHub labels. | +| [microvm\_runners\_map](#output\_microvm\_runners\_map) | Map of MicroVM runner keys to their provider-specific resources. | +| [runners\_arn\_map](#output\_runners\_arn\_map) | Map of runner keys to the IAM role ARNs used by their compute runtime. | +| [runners\_labels\_map](#output\_runners\_labels\_map) | Map of runner keys to their base and extra GitHub labels. | | [subnet\_cidr\_blocks](#output\_subnet\_cidr\_blocks) | Map of EC2 runner subnet IDs to their CIDR blocks. | | [webhook\_endpoint](#output\_webhook\_endpoint) | Public HTTPS endpoint URL for the GitHub Actions webhook relay. | diff --git a/modules/platform/ec2_deployment/ami.tf b/modules/platform/ec2_deployment/ami.tf index 967211818..e99f127a3 100644 --- a/modules/platform/ec2_deployment/ami.tf +++ b/modules/platform/ec2_deployment/ami.tf @@ -8,7 +8,7 @@ data "aws_ssm_parameter" "ami_id" { } data "aws_ami" "runner_ami" { - for_each = var.runner_configs.runner_specs + for_each = local.ec2_runner_configs most_recent = false filter { diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf index 07ae6ce17..174d90c84 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf +++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf @@ -1,21 +1,26 @@ -data "aws_caller_identity" "current" {} +data "aws_caller_identity" "current" { + count = length(local.ec2_runner_configs) > 0 ? 1 : 0 +} -data "aws_partition" "current" {} +data "aws_partition" "current" { + count = length(local.ec2_runner_configs) > 0 ? 1 : 0 +} locals { runner_ami_ssm_parameter_names = { - for key in keys(var.runner_configs.runner_specs) : + for key in keys(local.ec2_runner_configs) : key => "/github-action-runners/${var.runner_configs.prefix}/${key}/runners/config/ami_id" } runner_ami_ssm_parameter_arns = { for key, name in local.runner_ami_ssm_parameter_names : - key => "arn:${data.aws_partition.current.partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${name}" + key => "arn:${data.aws_partition.current[0].partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current[0].account_id}:parameter${name}" } } module "ec2_update_runner_ssm_ami" { source = "./ec2_update_runner_ssm_ami" + count = length(local.ec2_runner_configs) > 0 ? 1 : 0 providers = { aws = aws @@ -27,12 +32,12 @@ module "ec2_update_runner_ssm_ami" { tags = var.tenant_configs.tags runner_ami_map = { - for key in keys(var.runner_configs.runner_specs) : + for key, runner_config in local.ec2_runner_configs : key => { resource_ssm_id = local.runner_ami_ssm_parameter_arns[key] ssm_id = local.runner_ami_ssm_parameter_names[key] - ami_filter = var.runner_configs.runner_specs[key].ami_filter - ami_owners = var.runner_configs.runner_specs[key].ami_owners + ami_filter = runner_config.compute_provider.ec2.ami_filter + ami_owners = runner_config.compute_provider.ec2.ami_owners } } diff --git a/modules/platform/ec2_deployment/ec2_update_runner_tags.tf b/modules/platform/ec2_deployment/ec2_update_runner_tags.tf index 3c35f4145..1d7029a63 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_tags.tf +++ b/modules/platform/ec2_deployment/ec2_update_runner_tags.tf @@ -1,5 +1,6 @@ module "ec2_update_runner_tags" { source = "./ec2_update_runner_tags" + count = length(local.ec2_runner_configs) > 0 ? 1 : 0 providers = { aws = aws diff --git a/modules/platform/ec2_deployment/main.tf b/modules/platform/ec2_deployment/main.tf index e57b295ea..2fca166eb 100644 --- a/modules/platform/ec2_deployment/main.tf +++ b/modules/platform/ec2_deployment/main.tf @@ -5,7 +5,8 @@ locals { terraform_aws_github_runner_tags = merge( var.tenant_configs.tags, { - terraform-aws-github-runner-ref = "v7.10.1" + # Immutable head of upstream draft PR #5260. + terraform-aws-github-runner-ref = "961c1208a3831d19af8c0cfb43a7ed8b2d81e34b" } ) webhook_api_gateway_access_log_format = jsonencode({ @@ -62,12 +63,16 @@ resource "aws_kms_alias" "github" { } data "aws_subnet" "runner_subnet" { - for_each = toset(var.network_configs.subnet_ids) + for_each = local.active_ec2_subnet_ids id = each.value } data "external" "download_lambdas" { - program = ["bash", "${path.module}/scripts/download_lambdas.sh", "/tmp/${var.runner_configs.prefix}/", "v7.10.1", "github-aws-runners/terraform-aws-github-runner"] + # TODO: Provider-aware Lambda artifacts are not released yet. Draft plans + # must set USE_CACHE/CACHE_PATH to ZIPs built from upstream PR #5260. Once a + # provider-aware release exists, move this ref, the module source, and the + # terraform-aws-github-runner-ref tag to that release together. + program = ["bash", "${path.module}/scripts/download_lambdas.sh", "/tmp/${var.runner_configs.prefix}/", "961c1208a3831d19af8c0cfb43a7ed8b2d81e34b", "github-aws-runners/terraform-aws-github-runner"] } # --------------------------------------------------------------------------- @@ -83,9 +88,7 @@ locals { # Every runner OS delivers its (large) hook scripts via SSM; at job time the # runner runs a small wrapper (hook_job__.tftpl) that fetches the # param, decompresses it, and execs the real hook (hooks/job__). - hook_ssm_oses = toset([ - for key, val in var.runner_configs.runner_specs : val["runner_os"] - ]) + hook_ssm_oses = toset(values(local.active_ec2_runner_oses)) } resource "aws_ssm_parameter" "hook_job_started" { @@ -107,6 +110,8 @@ resource "aws_ssm_parameter" "hook_job_completed" { } data "aws_iam_policy_document" "runner_hooks_ssm_read" { + count = length(local.active_ec2_runner_keys) > 0 ? 1 : 0 + statement { sid = "ReadRunnerHookParameters" effect = "Allow" @@ -119,16 +124,18 @@ data "aws_iam_policy_document" "runner_hooks_ssm_read" { } resource "aws_iam_policy" "runner_hooks_ssm_read" { + count = length(local.active_ec2_runner_keys) > 0 ? 1 : 0 + name = "${var.runner_configs.prefix}-runner-hooks-ssm-read" description = "Allow runners to read their gzip'd job-hook scripts from SSM." - policy = data.aws_iam_policy_document.runner_hooks_ssm_read.json + policy = data.aws_iam_policy_document.runner_hooks_ssm_read[0].json tags = var.tenant_configs.tags } module "runners" { - #checkov:skip=CKV_TF_1:Module source uses Renovate-managed version tags; commit SHA pinning is an accepted policy tradeoff. - source = "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=v7.10.1" + #checkov:skip=CKV_TF_1:Draft integration is pinned to the immutable upstream PR head. + source = "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=961c1208a3831d19af8c0cfb43a7ed8b2d81e34b" aws_region = var.aws_region @@ -165,142 +172,10 @@ module "runners" { runner_binaries_syncer_lambda_zip = "${data.external.download_lambdas.result.path}/runner-binaries-syncer.zip" runners_lambda_zip = "${data.external.download_lambdas.result.path}/runners.zip" - # Configure the various types of runners we provide, along with on-demand - # versus standby pools, etc. - multi_runner_config = { - for key, val in var.runner_configs.runner_specs : - key => { - matcherConfig : { - # Generate all unique combinations of extra_labels and combine them with runner_labels - labelMatchers = concat( - [val["runner_labels"]], - concat([ - # Iterate over lengths from 1 to the length of extra_labels - for length in range(1, length(val["extra_labels"]) + 1) : concat([ - # For each length, iterate over starting positions to slice the extra_labels - for start in range(0, length(val["extra_labels"]) - length + 1) : - # Combine runner_labels and the current slice of extra_labels - concat(val["runner_labels"], slice(val["extra_labels"], start, start + length)) - ]) - ]...) - ) - exactMatch = true - enableDynamicLabels = val["enable_dynamic_labels"] - awsDynamicLabelsPolicy = val["aws_dynamic_labels_policy"] - } - redrive_build_queue = val["redrive_build_queue"] - runner_config = { - runner_metadata_options = { - "http_endpoint" : "enabled", - "http_put_response_hop_limit" : 2, - "http_tokens" : "optional", - "instance_metadata_tags" : "enabled" - } - delay_webhook_event = 0 - runner_ec2_tags = var.tenant_configs.tags - runner_os = val["runner_os"] - runner_architecture = val["runner_architecture"] - runner_extra_labels = val["extra_labels"] - enable_ssm_on_runners = true - instance_types = val["instance_types"] - runners_maximum_count = val["max_instances"] - scale_down_schedule_expression = "cron(*/5 * * * ? *)" - minimum_running_time_in_minutes = val["min_run_time"] - runner_group_name = var.runner_configs.runner_group_name - enable_runner_binaries_syncer = false - enable_userdata = val["enable_userdata"] - scale_errors = var.runner_configs.scale_errors - lambda_event_source_mapping_batch_size = val["lambda_event_source_mapping_batch_size"] - lambda_event_source_mapping_maximum_batching_window_in_seconds = val["lambda_event_source_mapping_maximum_batching_window_in_seconds"] - userdata_template = "${local.user_data_prefix}/user_data_${val["runner_os"]}.tftpl" - userdata_pre_install = "# No pre-install steps." - userdata_post_install = templatefile( - local.userdata_template_post_install, - { - runner_user = val["runner_user"] - ecr_registries = var.tenant_configs.ecr_registries - } - ) - # The real hook scripts live (gzip+base64) in SSM; the runner hook is a - # small wrapper that fetches+decompresses+runs them at job time. The - # wrapper is small enough to inline in user_data. See hooks/ for the real - # scripts and hook_job_*_.tftpl for the wrappers. - runner_hook_job_started = templatefile( - "${local.user_data_prefix}/hook_job_started_${val["runner_os"]}.tftpl", - { - param_name = aws_ssm_parameter.hook_job_started[val["runner_os"]].name - region = var.aws_region - } - ) - runner_hook_job_completed = templatefile( - "${local.user_data_prefix}/hook_job_completed_${val["runner_os"]}.tftpl", - { - param_name = aws_ssm_parameter.hook_job_completed[val["runner_os"]].name - region = var.aws_region - } - ) - enable_runner_detailed_monitoring = true - runner_run_as = val["runner_user"] - block_device_mappings = val["block_device_mappings"] - license_specifications = val["license_specifications"] - placement = val["placement"] - use_dedicated_host = val["use_dedicated_host"] - runner_log_files = concat( - // Linux/macOS-only logs - val["runner_os"] == "windows" ? [] : [ - { - "log_group_name" : "forge-logs", - "prefix_log_group" : true, - "file_path" : "/var/log/syslog", - "log_stream_name" : "{instance_id}/syslog" - }, - ], - // Logs that exist on all OSes, with OS-specific paths - [ - { - "log_group_name" : "forge-logs", - "prefix_log_group" : true, - "file_path" : val["runner_os"] == "windows" ? "C:/UserData.log" : "/var/log/user-data.log", - "log_stream_name" : "{instance_id}/user-data" - }, - { - "log_group_name" : "forge-logs", - "prefix_log_group" : true, - "file_path" : val["runner_os"] == "windows" ? "C:/actions-runner/_diag/Runner_*.log" : "/opt/actions-runner/_diag/Runner_**.log", - "log_stream_name" : "{instance_id}/runner" - }, - { - "log_group_name" : "forge-logs", - "prefix_log_group" : true, - "file_path" : val["runner_os"] == "windows" ? "C:/Users/Administrator/AppData/Local/Temp/hook_*.log" : "/home/${val["runner_user"]}/hook.log", - "log_stream_name" : "{instance_id}/hook" - }, - ], - ) - ami = { - owners = val["ami_owners"] - filter = val["ami_filter"] - kms_key_arn = val["ami_kms_key_arn"] - } - instance_target_capacity_type = val["instance_target_capacity_type"] - enable_job_queued_check = false - runner_iam_role_managed_policy_arns = concat( - var.runner_configs.runner_iam_role_managed_policy_arns, - [ - aws_iam_policy.ec2_tags.arn, - aws_iam_policy.runner_hooks_ssm_read.arn, - ], - ) - vpc_id = val["vpc_id"] - subnet_ids = val["subnet_ids"] - enable_ephemeral_runners = true - create_service_linked_role_spot = true - enable_organization_runners = true - job_queue_retention_in_seconds = 172800 - pool_config = val["pool_config"] - pool_runner_owner = var.runner_configs.ghes_org - } - } + multi_runner_config = {} + + experimental = { + multi_runner_config_v2 = local.multi_runner_config_v2 } depends_on = [ diff --git a/modules/platform/ec2_deployment/outputs.tf b/modules/platform/ec2_deployment/outputs.tf index f42d52f32..fec5ff42d 100644 --- a/modules/platform/ec2_deployment/outputs.tf +++ b/modules/platform/ec2_deployment/outputs.tf @@ -3,28 +3,78 @@ output "webhook_endpoint" { description = "Public HTTPS endpoint URL for the GitHub Actions webhook relay." } +output "runners_arn_map" { + value = merge( + { + for runner_key in keys(local.ec2_runner_configs) : + runner_key => module.runners.runners_map_v2[runner_key].runner.role.arn + }, + { + for runner_key in keys(local.microvm_runner_configs) : + runner_key => module.runners.runners_map_v2[runner_key].provider.microvm.execution_role_arn + }, + ) + description = "Map of runner keys to the IAM role ARNs used by their compute runtime." +} + +output "runners_labels_map" { + value = local.runner_labels + description = "Map of runner keys to their base and extra GitHub labels." +} + +output "ec2_runners_map" { + value = { + for runner_key in keys(local.ec2_runner_configs) : + runner_key => module.runners.runners_map_v2[runner_key].provider.ec2 + } + description = "Map of EC2 runner keys to their provider-specific resources." +} + output "ec2_runners_arn_map" { value = { - for runner_key, runner in module.runners.runners_map : runner_key => runner.role_runner[0].arn + for runner_key in keys(local.ec2_runner_configs) : + runner_key => module.runners.runners_map_v2[runner_key].runner.role.arn } description = "Map of EC2 runner keys to their IAM role ARNs." } output "ec2_runners_ami_name_map" { value = { - for runner_key, runner in module.runners.runners_map : runner_key => data.aws_ami.runner_ami[runner_key].name + for runner_key in keys(local.ec2_runner_configs) : runner_key => data.aws_ami.runner_ami[runner_key].name } description = "Map of EC2 runner keys to the AMI names used for each runner." } output "ec2_runners_labels_map" { value = { - for runner_key, spec in var.runner_configs.runner_specs : - runner_key => concat(spec.runner_labels, spec.extra_labels) + for runner_key in keys(local.ec2_runner_configs) : runner_key => local.runner_labels[runner_key] } description = "Map of EC2 runner keys to their base and extra GitHub labels." } +output "microvm_runners_map" { + value = { + for runner_key in keys(local.microvm_runner_configs) : + runner_key => module.runners.runners_map_v2[runner_key].provider.microvm + } + description = "Map of MicroVM runner keys to their provider-specific resources." +} + +output "microvm_runners_arn_map" { + value = { + for runner_key in keys(local.microvm_runner_configs) : + runner_key => module.runners.runners_map_v2[runner_key].provider.microvm.execution_role_arn + } + description = "Map of MicroVM runner keys to their execution role ARNs." +} + +output "microvm_runners_labels_map" { + value = { + for runner_key in keys(local.microvm_runner_configs) : runner_key => local.runner_labels[runner_key] + } + description = "Map of MicroVM runner keys to their base and extra GitHub labels." +} + output "subnet_cidr_blocks" { value = { for id, subnet in data.aws_subnet.runner_subnet : id => subnet.cidr_block } description = "Map of EC2 runner subnet IDs to their CIDR blocks." diff --git a/modules/platform/ec2_deployment/roles.tf b/modules/platform/ec2_deployment/roles.tf index ff708b96b..e6e2989ae 100644 --- a/modules/platform/ec2_deployment/roles.tf +++ b/modules/platform/ec2_deployment/roles.tf @@ -1,4 +1,6 @@ data "aws_iam_policy_document" "ec2_tags" { + count = length(local.active_ec2_runner_keys) > 0 ? 1 : 0 + statement { actions = [ "ec2:CreateTags" @@ -32,9 +34,11 @@ data "aws_iam_policy_document" "ec2_tags" { # Define the actual IAM policy for EC2 tags resource "aws_iam_policy" "ec2_tags" { + count = length(local.active_ec2_runner_keys) > 0 ? 1 : 0 + name = "${var.runner_configs.prefix}-policy-for-ec2-tags" description = "Policy that allows EC2 instances to create tags on themselves." - policy = data.aws_iam_policy_document.ec2_tags.json + policy = data.aws_iam_policy_document.ec2_tags[0].json tags = var.tenant_configs.tags tags_all = var.tenant_configs.tags diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf new file mode 100644 index 000000000..8c76a2769 --- /dev/null +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -0,0 +1,203 @@ +locals { + ec2_runner_configs = { + for key, runner_config in var.runner_configs.runner_specs : + key => runner_config + if runner_config.compute_provider.ec2 != null + } + + microvm_runner_configs = { + for key, runner_config in var.runner_configs.runner_specs : + key => runner_config + if runner_config.compute_provider.microvm != null + } + + active_ec2_runner_oses = { + for key, runner_config in local.ec2_runner_configs : + key => runner_config.runner_os + } + + active_ec2_runner_keys = toset(keys(local.ec2_runner_configs)) + + active_ec2_subnet_ids = toset(flatten([ + for runner_config in values(local.ec2_runner_configs) : + runner_config.compute_provider.ec2.subnet_ids == null ? var.network_configs.subnet_ids : runner_config.compute_provider.ec2.subnet_ids + ])) + + runner_labels = { + for key, runner_config in var.runner_configs.runner_specs : + key => concat(runner_config.runner_labels, runner_config.extra_labels) + } + + runner_iam_role_managed_policy_arns = { + for policy_index, policy_arn in var.runner_configs.runner_iam_role_managed_policy_arns : + "forge-${policy_index}" => policy_arn + } + + ec2_compute_provider = { + for key, runner_config in local.ec2_runner_configs : + key => { + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" + } + ami = { + owners = runner_config.compute_provider.ec2.ami_owners + filter = runner_config.compute_provider.ec2.ami_filter + kms_key = runner_config.compute_provider.ec2.ami_kms_key_arn == null ? null : { + arn = runner_config.compute_provider.ec2.ami_kms_key_arn + } + } + block_device_mappings = runner_config.compute_provider.ec2.block_device_mappings + create_service_linked_role_spot = true + cloudwatch_agent = { + enabled = true + } + binaries_syncer = { + enabled = false + } + detailed_monitoring_enabled = true + ssm_enabled = true + user_data = { + enabled = runner_config.compute_provider.ec2.enable_userdata + template = "${local.user_data_prefix}/user_data_${runner_config.runner_os}.tftpl" + pre_install = "# No pre-install steps." + post_install = templatefile( + local.userdata_template_post_install, + { + runner_user = runner_config.runner_user + ecr_registries = var.tenant_configs.ecr_registries + } + ) + } + instance_target_capacity_type = runner_config.compute_provider.ec2.instance_target_capacity_type + instance_types = runner_config.compute_provider.ec2.instance_types + scale_errors = runner_config.compute_provider.ec2.scale_errors + vpc_id = runner_config.compute_provider.ec2.vpc_id + subnet_ids = runner_config.compute_provider.ec2.subnet_ids + placement = runner_config.compute_provider.ec2.placement + license_specifications = runner_config.compute_provider.ec2.license_specifications + use_dedicated_host = runner_config.compute_provider.ec2.use_dedicated_host + log_files = concat( + runner_config.runner_os == "windows" ? [] : [ + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = "/var/log/syslog" + log_stream_name = "{instance_id}/syslog" + }, + ], + [ + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = runner_config.runner_os == "windows" ? "C:/UserData.log" : "/var/log/user-data.log" + log_stream_name = "{instance_id}/user-data" + }, + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = runner_config.runner_os == "windows" ? "C:/actions-runner/_diag/Runner_*.log" : "/opt/actions-runner/_diag/Runner_**.log" + log_stream_name = "{instance_id}/runner" + }, + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = runner_config.runner_os == "windows" ? "C:/Users/Administrator/AppData/Local/Temp/hook_*.log" : "/home/${runner_config.runner_user}/hook.log" + log_stream_name = "{instance_id}/hook" + }, + ], + ) + tags = var.tenant_configs.tags + } + } + + multi_runner_config_v2 = { + for key, runner_config in var.runner_configs.runner_specs : + key => { + runner = { + os = runner_config.runner_os + architecture = runner_config.runner_architecture + extra_labels = runner_config.extra_labels + group_name = var.runner_configs.runner_group_name + run_as = runner_config.runner_user + maximum_count = runner_config.max_instances + ephemeral = true + hooks = runner_config.compute_provider.ec2 == null ? {} : { + job_started = templatefile( + "${local.user_data_prefix}/hook_job_started_${runner_config.runner_os}.tftpl", + { + param_name = aws_ssm_parameter.hook_job_started[runner_config.runner_os].name + region = var.aws_region + } + ) + job_completed = templatefile( + "${local.user_data_prefix}/hook_job_completed_${runner_config.runner_os}.tftpl", + { + param_name = aws_ssm_parameter.hook_job_completed[runner_config.runner_os].name + region = var.aws_region + } + ) + } + iam = { + managed_policy_arns = merge( + local.runner_iam_role_managed_policy_arns, + runner_config.compute_provider.ec2 == null ? {} : { + forge_ec2_tags = aws_iam_policy.ec2_tags[0].arn + forge_runner_hooks_ssm = aws_iam_policy.runner_hooks_ssm_read[0].arn + }, + ) + } + } + + github = { + organization_runners = true + } + + queue = { + delay_webhook_event = 0 + job_queue_retention_in_seconds = 172800 + event_source_mapping = { + batch_size = runner_config.lambda_event_source_mapping_batch_size + maximum_batching_window_in_seconds = runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds + } + redrive_build_queue = runner_config.redrive_build_queue + } + + scale_up = { + job_queued_check_enabled = false + } + + scale_down = { + schedule_expression = "cron(*/5 * * * ? *)" + minimum_running_time_in_minutes = runner_config.min_run_time + } + + pool = { + config = runner_config.pool_config + runner_owner = var.runner_configs.ghes_org + } + + compute_provider = { + ec2 = runner_config.compute_provider.ec2 == null ? null : local.ec2_compute_provider[key] + microvm = runner_config.compute_provider.microvm + } + + matcherConfig = { + labelMatchers = length(runner_config.extra_labels) == 0 ? [runner_config.runner_labels] : concat( + [runner_config.runner_labels], + concat([ + for label_count in range(1, length(runner_config.extra_labels) + 1) : concat([ + for start in range(0, length(runner_config.extra_labels) - label_count + 1) : + concat(runner_config.runner_labels, slice(runner_config.extra_labels, start, start + label_count)) + ]) + ]...) + ) + exactMatch = true + enableDynamicLabels = runner_config.enable_dynamic_labels + awsDynamicLabelsPolicy = runner_config.aws_dynamic_labels_policy + } + } + } +} diff --git a/modules/platform/ec2_deployment/scripts/download_lambdas.sh b/modules/platform/ec2_deployment/scripts/download_lambdas.sh index cb65c1734..cd5b8d0b6 100755 --- a/modules/platform/ec2_deployment/scripts/download_lambdas.sh +++ b/modules/platform/ec2_deployment/scripts/download_lambdas.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -x +set -ex if [ "$#" -lt 3 ]; then echo "Usage: $0 " @@ -24,4 +24,11 @@ else wget --no-verbose -P "$DOWNLOAD_PATH" "https://github.com/${REPO}/releases/download/${VERSION}/webhook.zip" fi +for lambda_zip in runner-binaries-syncer runners webhook; do + if [ ! -f "${DOWNLOAD_PATH}/${lambda_zip}.zip" ]; then + echo "Missing ${DOWNLOAD_PATH}/${lambda_zip}.zip for terraform-aws-github-runner ${VERSION}." >&2 + exit 1 + fi +done + echo -n "{\"version\":\"${VERSION}\",\"path\":\"${DOWNLOAD_PATH}\",\"repo\":\"${REPO}\"}" diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl new file mode 100644 index 000000000..8872b6412 --- /dev/null +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -0,0 +1,221 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + arn = "arn:aws:iam::123456789012:user/test" + user_id = "AIDATEST" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + dns_suffix = "amazonaws.com" + } + } + + mock_data "aws_subnet" { + defaults = { + arn = "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-test" + availability_zone = "eu-west-1a" + cidr_block = "10.0.0.0/24" + vpc_id = "vpc-test" + } + } + + mock_data "aws_ssm_parameter" { + defaults = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/test" + name = "/test" + type = "String" + value = "ami-0123456789abcdef0" + } + } + + mock_data "aws_ami" { + defaults = { + architecture = "x86_64" + id = "ami-0123456789abcdef0" + image_type = "machine" + name = "forge-test-ami" + root_device_name = "/dev/xvda" + root_device_type = "ebs" + virtualization_type = "hvm" + } + } + + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/mock-runner" + } + } + + mock_resource "aws_iam_policy" { + defaults = { + arn = "arn:aws:iam::123456789012:policy/mock" + } + } + + mock_resource "aws_kms_key" { + defaults = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/00000000-0000-0000-0000-000000000000" + key_id = "00000000-0000-0000-0000-000000000000" + } + } + + mock_resource "aws_sqs_queue" { + defaults = { + arn = "arn:aws:sqs:eu-west-1:123456789012:mock" + id = "https://sqs.eu-west-1.amazonaws.com/123456789012/mock" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/mock" + } + } +} + +mock_provider "external" { + mock_data "external" { + defaults = { + result = { + path = "/private/tmp/forge-test-lambda-cache" + repo = "github-aws-runners/terraform-aws-github-runner" + version = "local-cache" + } + } + } +} + +mock_provider "archive" {} +mock_provider "local" {} +mock_provider "null" {} +mock_provider "random" {} + +variables { + aws_region = "eu-west-1" + + network_configs = { + vpc_id = "vpc-test" + subnet_ids = ["subnet-default"] + lambda_vpc_id = "vpc-test" + lambda_subnet_ids = ["subnet-test"] + } + + tenant_configs = { + ecr_registries = [] + tags = { + Environment = "test" + } + } + + runner_configs = { + env = "test" + prefix = "forge-test" + ghes_url = "" + ghes_org = "cisco-open" + log_level = "info" + logging_retention_in_days = "3" + github_app = { + key_base64 = "dGVzdA==" + id = "12345" + webhook_secret = "test" + } + runner_iam_role_managed_policy_arns = [] + runner_group_name = "Default" + runner_specs = { + ec2 = { + runner_labels = ["self-hosted", "ec2"] + runner_os = "linux" + runner_architecture = "x64" + extra_labels = [] + max_instances = 2 + min_run_time = 5 + pool_config = [] + runner_user = "ec2-user" + compute_provider = { + ec2 = { + ami_filter = { + name = ["forge-*"] + state = ["available"] + } + ami_kms_key_arn = null + ami_owners = ["123456789012"] + instance_types = ["m7i.large"] + enable_userdata = true + instance_target_capacity_type = "on-demand" + subnet_ids = ["subnet-override"] + block_device_mappings = [{ + delete_on_termination = true + device_name = "/dev/xvda" + encrypted = true + iops = 3000 + kms_key_id = null + snapshot_id = null + throughput = 125 + volume_size = 30 + volume_type = "gp3" + }] + } + } + } + + microvm = { + runner_labels = ["self-hosted", "microvm"] + runner_os = "linux" + runner_architecture = "x64" + extra_labels = [] + max_instances = 2 + min_run_time = 5 + pool_config = [] + runner_user = "runner" + compute_provider = { + microvm = { + image_identifier = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:test" + } + } + } + } + } +} + +run "mixed_provider_plan" { + command = plan + + plan_options { + target = [ + data.aws_subnet.runner_subnet, + module.runners.aws_sqs_queue.queued_builds, + ] + } + + assert { + condition = ( + toset(keys(local.ec2_runner_configs)) == toset(["ec2"]) + && toset(keys(local.multi_runner_config_v2)) == toset(["ec2", "microvm"]) + && local.active_ec2_subnet_ids == toset(["subnet-override"]) + ) + error_message = "Provider filtering and EC2 effective subnet resolution must preserve both provider lanes." + } + + assert { + condition = ( + local.multi_runner_config_v2.ec2.compute_provider.ec2 != null + && local.multi_runner_config_v2.ec2.compute_provider.microvm == null + && local.multi_runner_config_v2.microvm.compute_provider.ec2 == null + && local.multi_runner_config_v2.microvm.compute_provider.microvm.image_identifier == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:test" + ) + error_message = "The v2 translation must select exactly one configured provider per lane." + } + + assert { + condition = ( + length(local.multi_runner_config_v2.microvm.matcherConfig.labelMatchers) == 1 + && tolist(local.multi_runner_config_v2.microvm.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "microvm"]) + ) + error_message = "Empty extra labels must retain the base label matcher." + } +} diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index 481a95e60..0a8216095 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -17,7 +17,13 @@ run "platform_ec2_deployment_interface_contract" { "ec2_runners_ami_name_map", "ec2_runners_arn_map", "ec2_runners_labels_map", + "ec2_runners_map", "event_bus_name", + "microvm_runners_arn_map", + "microvm_runners_labels_map", + "microvm_runners_map", + "runners_arn_map", + "runners_labels_map", "subnet_cidr_blocks", "webhook_endpoint", ] @@ -44,31 +50,40 @@ run "platform_ec2_deployment_interface_contract" { "webhook_secret = string", "runner_iam_role_managed_policy_arns = list(string)", "runner_group_name = string", - "scale_errors = optional(list(string), [])", "runner_specs = map(object({", - "ami_filter = object({", - "name = list(string)", - "state = list(string)", - "ami_kms_key_arn = string", - "ami_owners = list(string)", - "runner_labels = list(string)", - "runner_os = string", - "runner_architecture = string", - "extra_labels = list(string)", - "enable_dynamic_labels = optional(bool, false)", - "aws_dynamic_labels_policy = optional(any, null)", + "runner_labels = list(string)", + "runner_os = string", + "runner_architecture = string", + "extra_labels = list(string)", + "enable_dynamic_labels = optional(bool, false)", + "aws_dynamic_labels_policy = optional(object({", + "blocked_keys = optional(list(string), [])", + "restricted_keys = optional(map(object({", + "allowed = optional(list(string), [])", + "denied = optional(list(string), [])", + "max = optional(string, null)", "lambda_event_source_mapping_batch_size = optional(number, 10)", "lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)", "redrive_build_queue = optional(object({", "enabled = optional(bool, true)", "maxReceiveCount = optional(number, 10)", - "}), {})", - "max_instances = number", - "min_run_time = number", - "instance_types = list(string)", + "max_instances = number", + "min_run_time = number", + "pool_config = list(object({", + "size = number", + "schedule_expression = string", + "schedule_expression_timezone = string", + "runner_user = string", + "compute_provider = object({", + "ec2 = optional(object({", + "ami_filter = object({", + "name = list(string)", + "state = list(string)", + "ami_kms_key_arn = string", + "ami_owners = list(string)", + "instance_types = list(string)", "license_specifications = optional(list(object({", "license_configuration_arn = string", - "})), null)", "placement = optional(object({", "affinity = optional(string)", "availability_zone = optional(string)", @@ -79,54 +94,85 @@ run "platform_ec2_deployment_interface_contract" { "spread_domain = optional(string)", "tenancy = optional(string)", "partition_number = optional(number)", - "}), null)", - "use_dedicated_host = optional(bool, false)", - "pool_config = list(object({", - "size = number", - "schedule_expression = string", - "schedule_expression_timezone = string", - "}))", - "runner_user = string", + "use_dedicated_host = optional(bool, false)", "enable_userdata = bool", "instance_target_capacity_type = string", "vpc_id = optional(string, null)", "subnet_ids = optional(list(string), null)", + "scale_errors = optional(list(string), [])", "block_device_mappings = list(object({", - "delete_on_termination = bool", - "device_name = string", - "encrypted = bool", - "iops = number", - "kms_key_id = string", - "snapshot_id = string", - "throughput = number", - "volume_size = number", - "volume_type = string", + "delete_on_termination = bool", + "device_name = string", + "encrypted = bool", + "iops = number", + "kms_key_id = string", + "snapshot_id = string", + "throughput = number", + "volume_initialization_rate = optional(number)", + "volume_size = number", + "volume_type = string", + "microvm = optional(object({", + "image_identifier = string", + "image_version = optional(string, null)", + "egress_network_connectors = optional(list(string), [])", + "idle_policy = optional(object({", + "max_idle_duration_seconds = number", + "suspended_duration_seconds = number", + "auto_resume_enabled = bool", + "logging = optional(object({", + "cloud_watch = optional(object({", + "log_group = optional(string, null)", + "log_stream = optional(string, null)", + "disabled = optional(bool, false)", + "run_hook_payload = optional(string, null)", + "maximum_duration_in_seconds = optional(number, null)", + "environment_variables = optional(map(string), {})", + "tags = optional(map(string), {})", + "iam = optional(object({", + "resource_arns = optional(list(string), [\"*\"])", + "scale_up = optional(list(string), null)", + "scale_down = optional(list(string), null)", + "additional_policy_json = optional(object({", + "managed_policy_arns = optional(object({", + "pool = optional(string, null)", + "for runner_config in values(var.runner_configs.runner_specs) :", + "if provider_config != null", + "error_message = \"Each runner_specs entry must configure exactly one compute provider: ec2 or microvm.\"", "variable \"tenant_configs\"", "ecr_registries = list(string)", "tags = map(string)", - "lambda_event_source_mapping_batch_size = val[\"lambda_event_source_mapping_batch_size\"]", - "lambda_event_source_mapping_maximum_batching_window_in_seconds = val[\"lambda_event_source_mapping_maximum_batching_window_in_seconds\"]", - "awsDynamicLabelsPolicy = val[\"aws_dynamic_labels_policy\"]", - "redrive_build_queue = val[\"redrive_build_queue\"]", - "output \"ec2_runners_ami_name_map\"", - "value = {", - "for runner_key, runner in module.runners.runners_map : runner_key => data.aws_ami.runner_ami[runner_key].name", - "description = \"Map of EC2 runner keys to the AMI names used for each runner.\"", + "if runner_config.compute_provider.ec2 != null", + "if runner_config.compute_provider.microvm != null", + "active_ec2_subnet_ids = toset(flatten([", + "scale_errors = runner_config.compute_provider.ec2.scale_errors", + "managed_policy_arns = merge(", + "local.runner_iam_role_managed_policy_arns,", + "forge_ec2_tags = aws_iam_policy.ec2_tags[0].arn", + "forge_runner_hooks_ssm = aws_iam_policy.runner_hooks_ssm_read[0].arn", + "labelMatchers = length(runner_config.extra_labels) == 0 ? [runner_config.runner_labels] : concat(", + "multi_runner_config = {}", + "multi_runner_config_v2 = local.multi_runner_config_v2", + "output \"runners_arn_map\"", + "runner_key => module.runners.runners_map_v2[runner_key].provider.microvm.execution_role_arn", + "output \"runners_labels_map\"", + "value = local.runner_labels", + "output \"ec2_runners_map\"", + "module.runners.runners_map_v2[runner_key].provider.ec2", "output \"ec2_runners_arn_map\"", - "for runner_key, runner in module.runners.runners_map : runner_key => runner.role_runner[0].arn", - "description = \"Map of EC2 runner keys to their IAM role ARNs.\"", + "module.runners.runners_map_v2[runner_key].runner.role.arn", + "output \"ec2_runners_ami_name_map\"", + "data.aws_ami.runner_ami[runner_key].name", "output \"ec2_runners_labels_map\"", - "runner_key => concat(spec.runner_labels, spec.extra_labels)", - "description = \"Map of EC2 runner keys to their base and extra GitHub labels.\"", + "output \"microvm_runners_map\"", + "module.runners.runners_map_v2[runner_key].provider.microvm", + "output \"microvm_runners_arn_map\"", + "output \"microvm_runners_labels_map\"", "output \"event_bus_name\"", "value = module.runners.webhook.eventbridge.event_bus.name", - "description = \"Name of the EventBridge event bus used by the webhook relay.\"", "output \"subnet_cidr_blocks\"", "value = { for id, subnet in data.aws_subnet.runner_subnet : id => subnet.cidr_block }", - "description = \"Map of EC2 runner subnet IDs to their CIDR blocks.\"", "output \"webhook_endpoint\"", "value = module.runners.webhook.endpoint", - "description = \"Public HTTPS endpoint URL for the GitHub Actions webhook relay.\"", ] } @@ -158,8 +204,8 @@ run "platform_ec2_deployment_interface_contract" { assert { condition = ( output.expected_input_variable_count == 4 - && output.expected_output_value_count == 6 - && output.expected_interface_literal_count == 105 + && output.expected_output_value_count == 12 + && output.expected_interface_literal_count == 145 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl index 8cb931b75..163b90249 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -28,9 +28,15 @@ run "platform_ec2_deployment_contract" { "data \"aws_iam_policy_document\" \"runner_hooks_ssm_read\"", "data \"aws_iam_policy_document\" \"ec2_tags\"", "output \"webhook_endpoint\"", + "output \"runners_arn_map\"", + "output \"runners_labels_map\"", + "output \"ec2_runners_map\"", "output \"ec2_runners_arn_map\"", "output \"ec2_runners_ami_name_map\"", "output \"ec2_runners_labels_map\"", + "output \"microvm_runners_map\"", + "output \"microvm_runners_arn_map\"", + "output \"microvm_runners_labels_map\"", "output \"subnet_cidr_blocks\"", "output \"event_bus_name\"", ] diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index c8a567e34..626935e23 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -18,67 +18,125 @@ variable "runner_configs" { }) runner_iam_role_managed_policy_arns = list(string) runner_group_name = string - scale_errors = optional(list(string), []) runner_specs = map(object({ - ami_filter = object({ - name = list(string) - state = list(string) - }) - ami_kms_key_arn = string - ami_owners = list(string) - runner_labels = list(string) - runner_os = string - runner_architecture = string - extra_labels = list(string) - enable_dynamic_labels = optional(bool, false) - aws_dynamic_labels_policy = optional(any, null) + runner_labels = list(string) + runner_os = string + runner_architecture = string + extra_labels = list(string) + enable_dynamic_labels = optional(bool, false) + aws_dynamic_labels_policy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) lambda_event_source_mapping_batch_size = optional(number, 10) lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0) redrive_build_queue = optional(object({ enabled = optional(bool, true) maxReceiveCount = optional(number, 10) }), {}) - max_instances = number - min_run_time = number - instance_types = list(string) - license_specifications = optional(list(object({ - license_configuration_arn = string - })), null) - placement = optional(object({ - affinity = optional(string) - availability_zone = optional(string) - group_id = optional(string) - group_name = optional(string) - host_id = optional(string) - host_resource_group_arn = optional(string) - spread_domain = optional(string) - tenancy = optional(string) - partition_number = optional(number) - }), null) - use_dedicated_host = optional(bool, false) + max_instances = number + min_run_time = number pool_config = list(object({ size = number schedule_expression = string schedule_expression_timezone = string })) - runner_user = string - enable_userdata = bool - instance_target_capacity_type = string - vpc_id = optional(string, null) - subnet_ids = optional(list(string), null) - block_device_mappings = list(object({ - delete_on_termination = bool - device_name = string - encrypted = bool - iops = number - kms_key_id = string - snapshot_id = string - throughput = number - volume_size = number - volume_type = string - })) + runner_user = string + compute_provider = object({ + ec2 = optional(object({ + ami_filter = object({ + name = list(string) + state = list(string) + }) + ami_kms_key_arn = string + ami_owners = list(string) + instance_types = list(string) + license_specifications = optional(list(object({ + license_configuration_arn = string + })), null) + placement = optional(object({ + affinity = optional(string) + availability_zone = optional(string) + group_id = optional(string) + group_name = optional(string) + host_id = optional(string) + host_resource_group_arn = optional(string) + spread_domain = optional(string) + tenancy = optional(string) + partition_number = optional(number) + }), null) + use_dedicated_host = optional(bool, false) + enable_userdata = bool + instance_target_capacity_type = string + vpc_id = optional(string, null) + subnet_ids = optional(list(string), null) + scale_errors = optional(list(string), []) + block_device_mappings = list(object({ + delete_on_termination = bool + device_name = string + encrypted = bool + iops = number + kms_key_id = string + snapshot_id = string + throughput = number + volume_initialization_rate = optional(number) + volume_size = number + volume_type = string + })) + }), null) + microvm = optional(object({ + image_identifier = string + image_version = optional(string, null) + egress_network_connectors = optional(list(string), []) + idle_policy = optional(object({ + max_idle_duration_seconds = number + suspended_duration_seconds = number + auto_resume_enabled = bool + }), null) + logging = optional(object({ + cloud_watch = optional(object({ + log_group = optional(string, null) + log_stream = optional(string, null) + }), null) + disabled = optional(bool, false) + }), null) + run_hook_payload = optional(string, null) + maximum_duration_in_seconds = optional(number, null) + environment_variables = optional(map(string), {}) + tags = optional(map(string), {}) + iam = optional(object({ + resource_arns = optional(list(string), ["*"]) + actions = optional(object({ + scale_up = optional(list(string), null) + scale_down = optional(list(string), null) + }), {}) + additional_policy_json = optional(object({ + scale_up = optional(string, null) + }), {}) + managed_policy_arns = optional(object({ + scale_up = optional(string, null) + pool = optional(string, null) + }), {}) + }), {}) + }), null) + }) })) }) + + validation { + condition = alltrue([ + for runner_config in values(var.runner_configs.runner_specs) : + length([ + for provider_type, provider_config in runner_config.compute_provider : provider_type + if provider_config != null + ]) == 1 + ]) + error_message = "Each runner_specs entry must configure exactly one compute provider: ec2 or microvm." + } } variable "network_configs" { diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index 99c08bf24..aa92d9ac5 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -4,11 +4,11 @@ This is the umbrella tenant module for Forge GitHub Actions runners. ## Why This Module Exists -Forge is a multi-tenant CI platform built around ephemeral runners, short-lived identity, and centralized operations. This module composes the tenant stack: EC2 runners, ARC runners, GitHub App settings, runner group reconciliation, trust validation, log archival, webhook relay, and self-healing support utilities. +Forge is a multi-tenant CI platform built around ephemeral runners, short-lived identity, and centralized operations. This module composes the tenant stack: EC2 and Lambda MicroVM compute runners, ARC runners, GitHub App settings, runner group reconciliation, trust validation, log archival, webhook relay, and self-healing support utilities. ## What It Manages -- EC2 runner deployment and ARC runner deployment. +- Provider-aware EC2/MicroVM compute deployment and ARC runner deployment. - GitHub App secret material in SSM Parameter Store. - IAM policies that let runners assume tenant-approved roles and pull allowed ECR images. - Runner group registration, trust validation, global lock, job log archival, webhook relay, and DLQ redrive helpers. @@ -18,7 +18,11 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived - This is the best starting point when onboarding or debugging a tenant because it shows how the platform pieces compose. - The tenant `deployment_config` defines the GitHub App, runner group, repository selection, and IAM bridge. -- The EC2 and ARC lanes can both be enabled for the same tenant; workflows choose by labels. +- EC2, MicroVM, and ARC lanes can be enabled for the same tenant; workflows choose by labels. +- `ec2_deployment_specs.runner_specs` now uses a breaking provider-aware shape; + every entry must select exactly one EC2 or MicroVM provider. +- MicroVM execution roles are module-managed so Forge workload policies stay + attached to the identity used by runner jobs. - Changing GitHub App or runner-group settings can affect job routing immediately. ## Deployment version inventory @@ -62,7 +66,7 @@ tag with the expected ref in `release_versions.yml`. | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.57.1 | +| [aws](#provider\_aws) | 6.58.0 | | [null](#provider\_null) | 3.3.0 | | [random](#provider\_random) | 3.9.0 | | [time](#provider\_time) | 0.14.0 | @@ -114,7 +118,7 @@ tag with the expected ref in `release_versions.yml`. | [aws\_region](#input\_aws\_region) | AWS region where Forge runners and supporting infrastructure are deployed. | `string` | n/a | yes | | [default\_tags](#input\_default\_tags) | A map of tags to apply to resources. | `map(string)` | n/a | yes | | [deployment\_config](#input\_deployment\_config) | High-level deployment configuration for a Forge runner installation.

Top-level fields:
- deployment\_prefix: Prefix used when naming resources (for example,
log groups, KMS keys, and SSM parameters).
- env : Logical environment name (for example, dev, stage,
prod). Used for tagging and dashboards.

github\_app object:
- id : Numeric GitHub App ID.
- client\_id : OAuth client ID for the app.
- installation\_id: GitHub App installation ID for this tenant.
- name : GitHub App name, used to build URLs and logs.

github object:
- ghes\_org : GitHub organization that owns the repos where
runners will be used.
- ghes\_url : GitHub.com or GHES base URL. Empty string implies
public github.com.
- repository\_selection: Scope for runners (all or selected repositories).
- runner\_group\_name : GitHub runner group to attach new runners to.

tenant object:
- name : Tenant identifier used in naming and
tagging.
- iam\_roles\_to\_assume : Optional list of IAM role ARNs that
runners are allowed to assume for workload execution.
- ecr\_registries : Optional list of ECR registry URLs that
runners may need to pull images from.
- github\_logs\_reader\_role\_arns: Optional list of IAM roles that can read
GitHub Actions logs for this tenant. |
object({
deployment_prefix = string
secret_suffix = string
env = string
github_app = object({
id = string
client_id = string
installation_id = string
name = string
})
github = object({
ghes_org = string
ghes_url = string
repository_selection = string
runner_group_name = string
})
tenant = object({
name = string
iam_roles_to_assume = optional(list(string), [])
ecr_registries = optional(list(string), [])
github_logs_reader_role_arns = optional(list(string), [])
})
})
| n/a | yes | -| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | EC2 deployment configuration for GitHub Actions runners.

Top-level fields:
- lambda\_subnet\_ids: Subnets where runner-related lambdas execute.
These can be more permissive than the runner subnets.
- subnet\_ids : Subnets where the EC2 runners are launched.
- vpc\_id : VPC that contains both runner and lambda subnets.
- runner\_specs : Map of runner pool keys to their EC2 sizing and
scheduling configuration.

runner\_specs[*] object fields:
- ami\_filter : Name/state filters used to select the runner AMI.
- ami\_kms\_key\_arn : KMS key ARN used to encrypt AMI EBS volumes.
- ami\_owners : List of AWS account IDs that own the AMI.
- runner\_labels : Base GitHub labels applied to jobs for this pool.
- runner\_os : Runner operating system (for example, linux).
- runner\_architecture: CPU architecture (for example, x86\_64 or arm64).
- extra\_labels : Additional GitHub labels that further specialize
this runner pool.
- enable\_dynamic\_labels: Enables dynamic `ghr-` labels for this runner
pool.
- aws\_dynamic\_labels\_policy: Optional policy for `ghr-ec2-*` labels for
this runner pool.
- lambda\_event\_source\_mapping\_batch\_size: Optional maximum number of queued
jobs passed to the scale-up Lambda per invocation.
- lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: Optional
maximum time to collect queued jobs before invoking the
scale-up Lambda.
- redrive\_build\_queue: Optional dead-letter queue redrive configuration.
Controls whether redrive is enabled and how many times a
message can be received before moving to the dead-letter
queue.
- max\_instances : Maximum number of EC2 runners in this pool.
- min\_run\_time : Minimum job run time (in minutes) before a runner
is eligible for scale-down.
- instance\_types : Allowed EC2 instance types for runners in this pool.
- placement : Optional EC2 placement configuration for the runner
launch template.
- license\_specifications: Optional EC2 License Manager configuration ARNs.
- use\_dedicated\_host: Whether this runner pool should use EC2 dedicated
hosts.
- pool\_config : List of pool size schedules (size + cron expression
and optional time zone) controlling baseline capacity.
- runner\_user : OS user under which the GitHub runner process runs.
- enable\_userdata : Whether the module should inject its standard
userdata to configure the runner VM.
- instance\_target\_capacity\_type: EC2 capacity type to use (spot or
on-demand).
- block\_device\_mappings: EBS volume configuration for the runner
instances, including size, type, encryption, and KMS. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
scale_errors = optional(list(string), [])
runner_specs = map(object({
ami_filter = object({
name = list(string)
state = list(string)
})
ami_kms_key_arn = string
ami_owners = list(string)
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(any, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
instance_types = list(string)
license_specifications = optional(list(object({
license_configuration_arn = string
})), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
use_dedicated_host = optional(bool, false)
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
enable_userdata = bool
instance_target_capacity_type = string
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
block_device_mappings = list(object({
delete_on_termination = bool
device_name = string
encrypted = bool
iops = number
kms_key_id = string
snapshot_id = string
throughput = number
volume_size = number
volume_type = string
}))
}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | Compute deployment configuration for GitHub Actions runners.

Top-level fields:
- lambda\_subnet\_ids: Subnets where runner-related lambdas execute.
These can be more permissive than the runner subnets.
- subnet\_ids : Default subnets for compute providers that use the VPC.
- vpc\_id : VPC that contains both runner and lambda subnets.
- runner\_specs : Map of provider-aware runner lanes.

runner\_specs[*] object fields:
- runner\_labels : Base GitHub labels applied to jobs for this pool.
- runner\_os : Runner operating system (for example, linux).
- runner\_architecture: CPU architecture (for example, x86\_64 or arm64).
- extra\_labels : Additional GitHub labels that further specialize
this runner pool.
- enable\_dynamic\_labels: Enables dynamic `ghr-` labels for this runner
pool.
- aws\_dynamic\_labels\_policy: Optional policy for `ghr-ec2-*` labels for
this runner pool.
- lambda\_event\_source\_mapping\_batch\_size: Optional maximum number of queued
jobs passed to the scale-up Lambda per invocation.
- lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: Optional
maximum time to collect queued jobs before invoking the
scale-up Lambda.
- redrive\_build\_queue: Optional dead-letter queue redrive configuration.
Controls whether redrive is enabled and how many times a
message can be received before moving to the dead-letter
queue.
- max\_instances : Maximum number of runners in this pool.
- min\_run\_time : Minimum job run time (in minutes) before a runner
is eligible for scale-down.
- pool\_config : List of pool size schedules (size + cron expression
and optional time zone) controlling baseline capacity.
- runner\_user : OS user under which the GitHub runner process runs.
- compute\_provider: Exactly one typed provider block: ec2 or microvm.

compute\_provider.ec2 fields:
- ami\_filter : Name/state filters used to select the runner AMI.
- ami\_kms\_key\_arn : KMS key ARN used to encrypt AMI EBS volumes.
- ami\_owners : List of AWS account IDs that own the AMI.
- instance\_types : Allowed EC2 instance types for runners in this pool.
- placement : Optional EC2 placement configuration.
- license\_specifications: Optional EC2 License Manager configuration ARNs.
- use\_dedicated\_host: Whether this runner pool should use dedicated hosts.
- enable\_userdata : Whether to inject the standard runner user data.
- instance\_target\_capacity\_type: EC2 capacity type (spot or on-demand).
- vpc\_id/subnet\_ids: Optional per-lane network overrides.
- block\_device\_mappings: EBS volume configuration for runner instances.
- scale\_errors : Retryable EC2 scale-up error codes.

compute\_provider.microvm fields:
- image\_identifier: ARN or ID of the Lambda MicroVM image.
- image\_version : Optional Lambda MicroVM image version.
- egress\_network\_connectors: Optional Lambda MicroVM network connectors.
- idle\_policy/logging/run\_hook\_payload: Optional runtime behavior.
- maximum\_duration\_in\_seconds: Optional maximum MicroVM lifetime.
- environment\_variables/tags: Provider-specific runtime configuration.
- iam : Optional MicroVM control-plane IAM overrides. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = optional(object({
ami_filter = object({
name = list(string)
state = list(string)
})
ami_kms_key_arn = string
ami_owners = list(string)
instance_types = list(string)
license_specifications = optional(list(object({
license_configuration_arn = string
})), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
use_dedicated_host = optional(bool, false)
enable_userdata = bool
instance_target_capacity_type = string
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
scale_errors = optional(list(string), [])
block_device_mappings = list(object({
delete_on_termination = bool
device_name = string
encrypted = bool
iops = number
kms_key_id = string
snapshot_id = string
throughput = number
volume_initialization_rate = optional(number)
volume_size = number
volume_type = string
}))
}), null)
microvm = optional(object({
image_identifier = string
image_version = optional(string, null)
egress_network_connectors = optional(list(string), [])
idle_policy = optional(object({
max_idle_duration_seconds = number
suspended_duration_seconds = number
auto_resume_enabled = bool
}), null)
logging = optional(object({
cloud_watch = optional(object({
log_group = optional(string, null)
log_stream = optional(string, null)
}), null)
disabled = optional(bool, false)
}), null)
run_hook_payload = optional(string, null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
tags = optional(map(string), {})
iam = optional(object({
resource_arns = optional(list(string), ["*"])
actions = optional(object({
scale_up = optional(list(string), null)
scale_down = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policy_arns = optional(object({
scale_up = optional(string, null)
pool = optional(string, null)
}), {})
}), {})
}), null)
})
}))
})
| n/a | yes | | [github\_webhook\_relay](#input\_github\_webhook\_relay) | Configuration for the (optional) webhook relay source module.
If enabled=true we provision the API Gateway + source EventBridge forwarding rule.
destination\_event\_bus\_name must already exist or be created in the destination account (or via the destination submodule run there). |
object({
enabled = bool
destination_account_id = optional(string)
destination_event_bus_name = optional(string)
destination_region = optional(string)
destination_reader_role_arn = optional(string)
})
|
{
"destination_account_id": "",
"destination_event_bus_name": "",
"destination_reader_role_arn": "",
"destination_region": "",
"enabled": false
}
| no | | [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | n/a | yes | | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Logging retention period in days. | `string` | n/a | yes | @@ -127,6 +131,6 @@ tag with the expected ref in `release_versions.yml`. | [forge\_core](#output\_forge\_core) | Core tenant-level metadata (non-sensitive). | | [forge\_github\_actions\_job\_logs](#output\_forge\_github\_actions\_job\_logs) | GitHub Actions job log archival resources. | | [forge\_github\_app](#output\_forge\_github\_app) | GitHub App related outputs. | -| [forge\_runners](#output\_forge\_runners) | Combined runners output (EC2 + ARC) | +| [forge\_runners](#output\_forge\_runners) | Combined compute-provider and ARC runner outputs. | | [forge\_webhook\_relay](#output\_forge\_webhook\_relay) | Webhook relay integration outputs. | diff --git a/modules/platform/forge_runners/ec2_runners.tf b/modules/platform/forge_runners/ec2_runners.tf index 009c0af64..bfe052de8 100644 --- a/modules/platform/forge_runners/ec2_runners.tf +++ b/modules/platform/forge_runners/ec2_runners.tf @@ -2,12 +2,12 @@ # For generating a webhook secret. Apparently this is a cryptographically secure # PRNG. resource "random_id" "random" { - count = length(var.ec2_deployment_specs.runner_specs) > 0 ? 1 : 0 + count = local.has_active_runners ? 1 : 0 byte_length = 20 } module "ec2_runners" { - count = length(var.ec2_deployment_specs.runner_specs) > 0 ? 1 : 0 + count = local.has_active_runners ? 1 : 0 # Using multi-runner example as a baseline. source = "../ec2_deployment" @@ -43,7 +43,6 @@ module "ec2_runners" { webhook_secret = aws_ssm_parameter.github_app_webhook_secret.value } runner_group_name = var.deployment_config.github.runner_group_name - scale_errors = var.ec2_deployment_specs.scale_errors runner_specs = var.ec2_deployment_specs.runner_specs } } diff --git a/modules/platform/forge_runners/forge_trust_validator.tf b/modules/platform/forge_runners/forge_trust_validator.tf index beb77721c..99314c55d 100644 --- a/modules/platform/forge_runners/forge_trust_validator.tf +++ b/modules/platform/forge_runners/forge_trust_validator.tf @@ -13,7 +13,7 @@ module "forge_trust_validator" { forge_iam_roles = { for idx, arn in values(merge( - try(module.ec2_runners[0].ec2_runners_arn_map, {}), + try(module.ec2_runners[0].runners_arn_map, {}), try(module.arc_runners.arc_runners_arn_map, {}), )) : idx => arn diff --git a/modules/platform/forge_runners/github_actions_job_log.tf b/modules/platform/forge_runners/github_actions_job_log.tf index 5a31aba66..c20b3af11 100644 --- a/modules/platform/forge_runners/github_actions_job_log.tf +++ b/modules/platform/forge_runners/github_actions_job_log.tf @@ -1,5 +1,5 @@ module "github_actions_job_logs" { - count = length(var.ec2_deployment_specs.runner_specs) > 0 ? 1 : 0 + count = local.has_active_runners ? 1 : 0 source = "./github_actions_job_logs" providers = { diff --git a/modules/platform/forge_runners/locals.tf b/modules/platform/forge_runners/locals.tf index b7980b2c4..4d7fb35ed 100644 --- a/modules/platform/forge_runners/locals.tf +++ b/modules/platform/forge_runners/locals.tf @@ -1,4 +1,12 @@ locals { + active_runner_keys = toset(keys(var.ec2_deployment_specs.runner_specs)) + has_active_runners = length(local.active_runner_keys) > 0 + + active_redrive_runner_keys = toset([ + for runner_key, runner_config in var.ec2_deployment_specs.runner_specs : runner_key + if runner_config.redrive_build_queue.enabled + ]) + runner_iam_role_managed_policy_arns = concat( # If the policy exists, include it, otherwise skip it length(var.deployment_config.tenant.iam_roles_to_assume) > 0 ? [aws_iam_policy.role_assumption_for_forge_runners[0].arn] : [], diff --git a/modules/platform/forge_runners/outputs.tf b/modules/platform/forge_runners/outputs.tf index d03f7b97e..951c82499 100644 --- a/modules/platform/forge_runners/outputs.tf +++ b/modules/platform/forge_runners/outputs.tf @@ -7,14 +7,23 @@ output "forge_core" { } output "forge_runners" { - description = "Combined runners output (EC2 + ARC)" + description = "Combined compute-provider and ARC runner outputs." value = { + compute = { + runners_arn_map = try(module.ec2_runners[0].runners_arn_map, {}) + runner_labels = try(module.ec2_runners[0].runners_labels_map, {}) + } ec2 = { runners_arn_map = try(module.ec2_runners[0].ec2_runners_arn_map, {}) ami_name_map = try(module.ec2_runners[0].ec2_runners_ami_name_map, {}) subnet_cidr_blocks = try(module.ec2_runners[0].subnet_cidr_blocks, []) runner_labels = try(module.ec2_runners[0].ec2_runners_labels_map, {}) } + microvm = { + runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {}) + runners = try(module.ec2_runners[0].microvm_runners_map, {}) + runner_labels = try(module.ec2_runners[0].microvm_runners_labels_map, {}) + } arc = { cluster_name = try(module.arc_runners.arc_cluster_name, {}) runners_arn_map = try(module.arc_runners.arc_runners_arn_map, {}) diff --git a/modules/platform/forge_runners/redrive_deadletter.tf b/modules/platform/forge_runners/redrive_deadletter.tf index 35ee1a787..b862a054a 100644 --- a/modules/platform/forge_runners/redrive_deadletter.tf +++ b/modules/platform/forge_runners/redrive_deadletter.tf @@ -3,6 +3,7 @@ locals { } module "redrive_deadletter" { + count = local.has_active_runners ? 1 : 0 source = "./redrive_deadletter" providers = { @@ -16,7 +17,7 @@ module "redrive_deadletter" { sqs_map = merge( { - for key in keys(var.ec2_deployment_specs.runner_specs) : + for key in local.active_redrive_runner_keys : key => { dlq = "${local.sqs_prefix_arn}:${var.deployment_config.deployment_prefix}-${key}-queued-builds_dead_letter" main = "${local.sqs_prefix_arn}:${var.deployment_config.deployment_prefix}-${key}-queued-builds" diff --git a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl index b21bc69a2..362cc84ea 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -138,30 +138,39 @@ run "platform_forge_runners_interface_contract" { "subnet_ids = list(string)", "lambda_vpc_id = string", "vpc_id = string", - "scale_errors = optional(list(string), [])", - "ami_filter = object({", - "name = list(string)", - "state = list(string)", - "ami_kms_key_arn = string", - "ami_owners = list(string)", - "runner_labels = list(string)", - "runner_os = string", - "runner_architecture = string", - "extra_labels = list(string)", - "enable_dynamic_labels = optional(bool, false)", - "aws_dynamic_labels_policy = optional(any, null)", + "runner_labels = list(string)", + "runner_os = string", + "runner_architecture = string", + "extra_labels = list(string)", + "enable_dynamic_labels = optional(bool, false)", + "aws_dynamic_labels_policy = optional(object({", + "blocked_keys = optional(list(string), [])", + "restricted_keys = optional(map(object({", + "allowed = optional(list(string), [])", + "denied = optional(list(string), [])", + "max = optional(string, null)", "lambda_event_source_mapping_batch_size = optional(number, 10)", "lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)", "redrive_build_queue = optional(object({", "enabled = optional(bool, true)", "maxReceiveCount = optional(number, 10)", - "}), {})", - "max_instances = number", - "min_run_time = number", - "instance_types = list(string)", + "max_instances = number", + "min_run_time = number", + "pool_config = list(object({", + "size = number", + "schedule_expression = string", + "schedule_expression_timezone = string", + "runner_user = string", + "compute_provider = object({", + "ec2 = optional(object({", + "ami_filter = object({", + "name = list(string)", + "state = list(string)", + "ami_kms_key_arn = string", + "ami_owners = list(string)", + "instance_types = list(string)", "license_specifications = optional(list(object({", "license_configuration_arn = string", - "})), null)", "placement = optional(object({", "affinity = optional(string)", "availability_zone = optional(string)", @@ -172,66 +181,68 @@ run "platform_forge_runners_interface_contract" { "spread_domain = optional(string)", "tenancy = optional(string)", "partition_number = optional(number)", - "}), null)", - "use_dedicated_host = optional(bool, false)", - "pool_config = list(object({", - "size = number", - "schedule_expression = string", - "schedule_expression_timezone = string", - "runner_user = string", + "use_dedicated_host = optional(bool, false)", "enable_userdata = bool", "instance_target_capacity_type = string", "vpc_id = optional(string, null)", "subnet_ids = optional(list(string), null)", "block_device_mappings = list(object({", - "delete_on_termination = bool", - "device_name = string", - "encrypted = bool", - "iops = number", - "kms_key_id = string", - "snapshot_id = string", - "throughput = number", - "volume_size = number", - "volume_type = string", - "EC2 deployment configuration for GitHub Actions runners.", + "delete_on_termination = bool", + "device_name = string", + "encrypted = bool", + "iops = number", + "kms_key_id = string", + "snapshot_id = string", + "throughput = number", + "volume_initialization_rate = optional(number)", + "volume_size = number", + "volume_type = string", + "scale_errors = optional(list(string), [])", + "microvm = optional(object({", + "image_identifier = string", + "image_version = optional(string, null)", + "egress_network_connectors = optional(list(string), [])", + "idle_policy = optional(object({", + "max_idle_duration_seconds = number", + "suspended_duration_seconds = number", + "auto_resume_enabled = bool", + "logging = optional(object({", + "cloud_watch = optional(object({", + "log_group = optional(string, null)", + "log_stream = optional(string, null)", + "disabled = optional(bool, false)", + "run_hook_payload = optional(string, null)", + "maximum_duration_in_seconds = optional(number, null)", + "environment_variables = optional(map(string), {})", + "tags = optional(map(string), {})", + "iam = optional(object({", + "resource_arns = optional(list(string), [\"*\"])", + "actions = optional(object({", + "scale_up = optional(list(string), null)", + "scale_down = optional(list(string), null)", + "additional_policy_json = optional(object({", + "scale_up = optional(string, null)", + "managed_policy_arns = optional(object({", + "pool = optional(string, null)", + "for runner_config in values(var.ec2_deployment_specs.runner_specs) :", + "for provider_type, provider_config in runner_config.compute_provider : provider_type", + "if provider_config != null", + "error_message = \"Each runner_specs entry must configure exactly one compute provider: ec2 or microvm.\"", + "Compute deployment configuration for GitHub Actions runners.", "- lambda_subnet_ids: Subnets where runner-related lambdas execute.", "These can be more permissive than the runner subnets.", - "- subnet_ids : Subnets where the EC2 runners are launched.", + "- subnet_ids : Default subnets for compute providers that use the VPC.", "- vpc_id : VPC that contains both runner and lambda subnets.", - "- runner_specs : Map of runner pool keys to their EC2 sizing and", - "scheduling configuration.", - "- ami_filter : Name/state filters used to select the runner AMI.", - "- ami_kms_key_arn : KMS key ARN used to encrypt AMI EBS volumes.", - "- ami_owners : List of AWS account IDs that own the AMI.", + "- runner_specs : Map of provider-aware runner lanes.", "- runner_labels : Base GitHub labels applied to jobs for this pool.", "- runner_os : Runner operating system (for example, linux).", - "- runner_architecture: CPU architecture (for example, x86_64 or arm64).", - "- extra_labels : Additional GitHub labels that further specialize", - "this runner pool.", - "- enable_dynamic_labels: Enables dynamic `ghr-` labels for this runner", - "pool.", - "- aws_dynamic_labels_policy: Optional policy for `ghr-ec2-*` labels for", - "- lambda_event_source_mapping_batch_size: Optional maximum number of queued", - "- lambda_event_source_mapping_maximum_batching_window_in_seconds: Optional", "- redrive_build_queue: Optional dead-letter queue redrive configuration.", - "- max_instances : Maximum number of EC2 runners in this pool.", - "- min_run_time : Minimum job run time (in minutes) before a runner", - "is eligible for scale-down.", - "- instance_types : Allowed EC2 instance types for runners in this pool.", - "- placement : Optional EC2 placement configuration for the runner", - "launch template.", - "- license_specifications: Optional EC2 License Manager configuration ARNs.", - "- use_dedicated_host: Whether this runner pool should use EC2 dedicated", - "hosts.", - "- pool_config : List of pool size schedules (size + cron expression", - "and optional time zone) controlling baseline capacity.", - "- runner_user : OS user under which the GitHub runner process runs.", - "- enable_userdata : Whether the module should inject its standard", - "userdata to configure the runner VM.", - "- instance_target_capacity_type: EC2 capacity type to use (spot or", - "on-demand).", - "- block_device_mappings: EBS volume configuration for the runner", - "instances, including size, type, encryption, and KMS.", + "- compute_provider: Exactly one typed provider block: ec2 or microvm.", + "compute_provider.ec2 fields:", + "- scale_errors : Retryable EC2 scale-up error codes.", + "compute_provider.microvm fields:", + "- image_identifier: ARN or ID of the Lambda MicroVM image.", + "- iam : Optional MicroVM control-plane IAM overrides.", "variable \"github_webhook_relay\"", "Configuration for the (optional) webhook relay source module.", "If enabled=true we provision the API Gateway + source EventBridge forwarding rule.", @@ -270,12 +281,19 @@ run "platform_forge_runners_interface_contract" { "name = var.deployment_config.github_app.name", "sensitive = true", "output \"forge_runners\"", - "description = \"Combined runners output (EC2 + ARC)\"", + "description = \"Combined compute-provider and ARC runner outputs.\"", + "compute = {", + "runners_arn_map = try(module.ec2_runners[0].runners_arn_map, {})", + "runner_labels = try(module.ec2_runners[0].runners_labels_map, {})", "ec2 = {", "runners_arn_map = try(module.ec2_runners[0].ec2_runners_arn_map, {})", "ami_name_map = try(module.ec2_runners[0].ec2_runners_ami_name_map, {})", "subnet_cidr_blocks = try(module.ec2_runners[0].subnet_cidr_blocks, [])", "runner_labels = try(module.ec2_runners[0].ec2_runners_labels_map, {})", + "microvm = {", + "runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {})", + "runners = try(module.ec2_runners[0].microvm_runners_map, {})", + "runner_labels = try(module.ec2_runners[0].microvm_runners_labels_map, {})", "arc = {", "cluster_name = try(module.arc_runners.arc_cluster_name, {})", "runners_arn_map = try(module.arc_runners.arc_runners_arn_map, {})", @@ -318,7 +336,7 @@ run "platform_forge_runners_interface_contract" { condition = ( output.expected_input_variable_count == 10 && output.expected_output_value_count == 5 - && output.expected_interface_literal_count == 259 + && output.expected_interface_literal_count == 277 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl index 1ff3bef86..3a66c6244 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -17,6 +17,16 @@ run "platform_forge_runners_contract" { "module \"github_webhook_relay\"", "module \"redrive_deadletter\"", "resource \"random_id\" \"random\"", + "active_runner_keys = toset(keys(var.ec2_deployment_specs.runner_specs))", + "runner_specs = var.ec2_deployment_specs.runner_specs", + "compute_provider = object({", + "ec2 = optional(object({", + "microvm = optional(object({", + "scale_errors = optional(list(string), [])", + "for key in local.active_redrive_runner_keys :", + "try(module.ec2_runners[0].runners_arn_map, {}),", + "runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {})", + "runners = try(module.ec2_runners[0].microvm_runners_map, {})", "resource \"aws_iam_policy\" \"role_assumption_for_forge_runners\"", "resource \"aws_iam_policy\" \"ecr_access_for_ec2_instances\"", "resource \"aws_servicecatalogappregistry_application\" \"forge\"", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index f286df895..4e763982c 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -14,83 +14,137 @@ variable "ec2_deployment_specs" { subnet_ids = list(string) lambda_vpc_id = string vpc_id = string - scale_errors = optional(list(string), []) runner_specs = map(object({ - ami_filter = object({ - name = list(string) - state = list(string) - }) - ami_kms_key_arn = string - ami_owners = list(string) - runner_labels = list(string) - runner_os = string - runner_architecture = string - extra_labels = list(string) - enable_dynamic_labels = optional(bool, false) - aws_dynamic_labels_policy = optional(any, null) + runner_labels = list(string) + runner_os = string + runner_architecture = string + extra_labels = list(string) + enable_dynamic_labels = optional(bool, false) + aws_dynamic_labels_policy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) lambda_event_source_mapping_batch_size = optional(number, 10) lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0) redrive_build_queue = optional(object({ enabled = optional(bool, true) maxReceiveCount = optional(number, 10) }), {}) - max_instances = number - min_run_time = number - instance_types = list(string) - license_specifications = optional(list(object({ - license_configuration_arn = string - })), null) - placement = optional(object({ - affinity = optional(string) - availability_zone = optional(string) - group_id = optional(string) - group_name = optional(string) - host_id = optional(string) - host_resource_group_arn = optional(string) - spread_domain = optional(string) - tenancy = optional(string) - partition_number = optional(number) - }), null) - use_dedicated_host = optional(bool, false) + max_instances = number + min_run_time = number pool_config = list(object({ size = number schedule_expression = string schedule_expression_timezone = string })) - runner_user = string - enable_userdata = bool - instance_target_capacity_type = string - vpc_id = optional(string, null) - subnet_ids = optional(list(string), null) - block_device_mappings = list(object({ - delete_on_termination = bool - device_name = string - encrypted = bool - iops = number - kms_key_id = string - snapshot_id = string - throughput = number - volume_size = number - volume_type = string - })) + runner_user = string + compute_provider = object({ + ec2 = optional(object({ + ami_filter = object({ + name = list(string) + state = list(string) + }) + ami_kms_key_arn = string + ami_owners = list(string) + instance_types = list(string) + license_specifications = optional(list(object({ + license_configuration_arn = string + })), null) + placement = optional(object({ + affinity = optional(string) + availability_zone = optional(string) + group_id = optional(string) + group_name = optional(string) + host_id = optional(string) + host_resource_group_arn = optional(string) + spread_domain = optional(string) + tenancy = optional(string) + partition_number = optional(number) + }), null) + use_dedicated_host = optional(bool, false) + enable_userdata = bool + instance_target_capacity_type = string + vpc_id = optional(string, null) + subnet_ids = optional(list(string), null) + scale_errors = optional(list(string), []) + block_device_mappings = list(object({ + delete_on_termination = bool + device_name = string + encrypted = bool + iops = number + kms_key_id = string + snapshot_id = string + throughput = number + volume_initialization_rate = optional(number) + volume_size = number + volume_type = string + })) + }), null) + microvm = optional(object({ + image_identifier = string + image_version = optional(string, null) + egress_network_connectors = optional(list(string), []) + idle_policy = optional(object({ + max_idle_duration_seconds = number + suspended_duration_seconds = number + auto_resume_enabled = bool + }), null) + logging = optional(object({ + cloud_watch = optional(object({ + log_group = optional(string, null) + log_stream = optional(string, null) + }), null) + disabled = optional(bool, false) + }), null) + run_hook_payload = optional(string, null) + maximum_duration_in_seconds = optional(number, null) + environment_variables = optional(map(string), {}) + tags = optional(map(string), {}) + iam = optional(object({ + resource_arns = optional(list(string), ["*"]) + actions = optional(object({ + scale_up = optional(list(string), null) + scale_down = optional(list(string), null) + }), {}) + additional_policy_json = optional(object({ + scale_up = optional(string, null) + }), {}) + managed_policy_arns = optional(object({ + scale_up = optional(string, null) + pool = optional(string, null) + }), {}) + }), {}) + }), null) + }) })) }) + validation { + condition = alltrue([ + for runner_config in values(var.ec2_deployment_specs.runner_specs) : + length([ + for provider_type, provider_config in runner_config.compute_provider : provider_type + if provider_config != null + ]) == 1 + ]) + error_message = "Each runner_specs entry must configure exactly one compute provider: ec2 or microvm." + } + description = <<-EOT - EC2 deployment configuration for GitHub Actions runners. + Compute deployment configuration for GitHub Actions runners. Top-level fields: - lambda_subnet_ids: Subnets where runner-related lambdas execute. These can be more permissive than the runner subnets. - - subnet_ids : Subnets where the EC2 runners are launched. + - subnet_ids : Default subnets for compute providers that use the VPC. - vpc_id : VPC that contains both runner and lambda subnets. - - runner_specs : Map of runner pool keys to their EC2 sizing and - scheduling configuration. + - runner_specs : Map of provider-aware runner lanes. runner_specs[*] object fields: - - ami_filter : Name/state filters used to select the runner AMI. - - ami_kms_key_arn : KMS key ARN used to encrypt AMI EBS volumes. - - ami_owners : List of AWS account IDs that own the AMI. - runner_labels : Base GitHub labels applied to jobs for this pool. - runner_os : Runner operating system (for example, linux). - runner_architecture: CPU architecture (for example, x86_64 or arm64). @@ -109,24 +163,36 @@ variable "ec2_deployment_specs" { Controls whether redrive is enabled and how many times a message can be received before moving to the dead-letter queue. - - max_instances : Maximum number of EC2 runners in this pool. + - max_instances : Maximum number of runners in this pool. - min_run_time : Minimum job run time (in minutes) before a runner is eligible for scale-down. - - instance_types : Allowed EC2 instance types for runners in this pool. - - placement : Optional EC2 placement configuration for the runner - launch template. - - license_specifications: Optional EC2 License Manager configuration ARNs. - - use_dedicated_host: Whether this runner pool should use EC2 dedicated - hosts. - pool_config : List of pool size schedules (size + cron expression and optional time zone) controlling baseline capacity. - runner_user : OS user under which the GitHub runner process runs. - - enable_userdata : Whether the module should inject its standard - userdata to configure the runner VM. - - instance_target_capacity_type: EC2 capacity type to use (spot or - on-demand). - - block_device_mappings: EBS volume configuration for the runner - instances, including size, type, encryption, and KMS. + - compute_provider: Exactly one typed provider block: ec2 or microvm. + + compute_provider.ec2 fields: + - ami_filter : Name/state filters used to select the runner AMI. + - ami_kms_key_arn : KMS key ARN used to encrypt AMI EBS volumes. + - ami_owners : List of AWS account IDs that own the AMI. + - instance_types : Allowed EC2 instance types for runners in this pool. + - placement : Optional EC2 placement configuration. + - license_specifications: Optional EC2 License Manager configuration ARNs. + - use_dedicated_host: Whether this runner pool should use dedicated hosts. + - enable_userdata : Whether to inject the standard runner user data. + - instance_target_capacity_type: EC2 capacity type (spot or on-demand). + - vpc_id/subnet_ids: Optional per-lane network overrides. + - block_device_mappings: EBS volume configuration for runner instances. + - scale_errors : Retryable EC2 scale-up error codes. + + compute_provider.microvm fields: + - image_identifier: ARN or ID of the Lambda MicroVM image. + - image_version : Optional Lambda MicroVM image version. + - egress_network_connectors: Optional Lambda MicroVM network connectors. + - idle_policy/logging/run_hook_payload: Optional runtime behavior. + - maximum_duration_in_seconds: Optional maximum MicroVM lifetime. + - environment_variables/tags: Provider-specific runtime configuration. + - iam : Optional MicroVM control-plane IAM overrides. EOT } From 213ddf1d688da6216f4a9cfeb9336b44ff0949a0 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 11 Aug 2026 13:37:31 +0200 Subject: [PATCH 2/5] refactor(runners): remove unnecessary data source counts --- .../ec2_deployment/ec2_update_runner_ssm_ami.tf | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf index 174d90c84..79994bf16 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf +++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf @@ -1,10 +1,6 @@ -data "aws_caller_identity" "current" { - count = length(local.ec2_runner_configs) > 0 ? 1 : 0 -} +data "aws_caller_identity" "current" {} -data "aws_partition" "current" { - count = length(local.ec2_runner_configs) > 0 ? 1 : 0 -} +data "aws_partition" "current" {} locals { runner_ami_ssm_parameter_names = { @@ -14,7 +10,7 @@ locals { runner_ami_ssm_parameter_arns = { for key, name in local.runner_ami_ssm_parameter_names : - key => "arn:${data.aws_partition.current[0].partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current[0].account_id}:parameter${name}" + key => "arn:${data.aws_partition.current.partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${name}" } } From 1a405f86889efeaa89b155748842e05087b55524 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 11 Aug 2026 14:42:24 +0200 Subject: [PATCH 3/5] feat(runners): mirror upstream EC2 provider contract --- .../main/tenants/acme/runner_settings.hcl | 33 +++- .../platform/tenant/runner_settings.hcl | 33 +++- modules/platform/ec2_deployment/README.md | 8 +- .../ec2_update_runner_ssm_ami.tf | 20 ++- .../ec2_update_runner_ssm_ami/README.md | 2 +- .../tests/interface_contract.tftest.hcl | 8 +- .../ec2_update_runner_ssm_ami/variables.tf | 7 +- .../ec2_deployment/runner_configs_v2.tf | 149 ++++++++-------- .../ec2_deployment/tests/behavior.tftest.hcl | 46 +++-- .../tests/interface_contract.tftest.hcl | 112 +++++++++--- modules/platform/ec2_deployment/variables.tf | 138 ++++++++++++--- modules/platform/forge_runners/README.md | 2 +- .../tests/interface_contract.tftest.hcl | 98 ++++++++--- .../tests/source_inventory.tftest.hcl | 2 +- modules/platform/forge_runners/variables.tf | 161 +++++++++++++----- 15 files changed, 578 insertions(+), 241 deletions(-) diff --git a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl index 8521a8892..828bb33fe 100644 --- a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl +++ b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl @@ -88,13 +88,34 @@ locals { pool_config = spec.pool_config compute_provider = { ec2 = { - ami_filter = { - name = [spec.ami_name] - state = ["available"] + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" + } + ami = { + filter = { + name = [spec.ami_name] + state = ["available"] + } + owners = [spec.ami_owner] + kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { + arn = spec.ami_kms_key_arn + } + } + create_service_linked_role_spot = true + cloudwatch_agent = { + enabled = true + } + binaries_syncer = { + enabled = false + } + detailed_monitoring_enabled = true + ssm_enabled = true + user_data = { + enabled = true } - ami_owners = [spec.ami_owner] - ami_kms_key_arn = trimspace(spec.ami_kms_key_arn) == "" ? null : spec.ami_kms_key_arn - enable_userdata = true instance_target_capacity_type = "on-demand" instance_types = spec.instance_types placement = try(spec.placement, null) diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index 8521a8892..828bb33fe 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -88,13 +88,34 @@ locals { pool_config = spec.pool_config compute_provider = { ec2 = { - ami_filter = { - name = [spec.ami_name] - state = ["available"] + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" + } + ami = { + filter = { + name = [spec.ami_name] + state = ["available"] + } + owners = [spec.ami_owner] + kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { + arn = spec.ami_kms_key_arn + } + } + create_service_linked_role_spot = true + cloudwatch_agent = { + enabled = true + } + binaries_syncer = { + enabled = false + } + detailed_monitoring_enabled = true + ssm_enabled = true + user_data = { + enabled = true } - ami_owners = [spec.ami_owner] - ami_kms_key_arn = trimspace(spec.ami_kms_key_arn) == "" ? null : spec.ami_kms_key_arn - enable_userdata = true instance_target_capacity_type = "on-demand" instance_types = spec.instance_types placement = try(spec.placement, null) diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index a369a1950..23cd8ebed 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -25,6 +25,12 @@ by the MicroVM image catalog. - This is a breaking input migration: every `runner_specs` entry must contain exactly one non-null `compute_provider.ec2` or `compute_provider.microvm` block. The legacy flat EC2 shape is not accepted. +- The EC2 block mirrors the upstream v2 nested contract. Forge still owns AMI + refresh and runner instance profiles, so EC2 lanes require a non-null, + module-managed `ami` block and cannot select `ami.id_ssm_parameter` or an + external `instance_profile`. The scheduled refresh uses the same default AMI + name filter as the upstream EC2 provider for each runner OS and architecture; + values in `ami.filter` override those defaults. - The upstream v2 path changes Terraform resource addresses from the v1 runner modules to provider-oriented runner stacks; this module does not include an in-place state migration. @@ -90,7 +96,7 @@ by the MicroVM image catalog. | ---- | ----------- | ---- | ------- | :------: | | [aws\_region](#input\_aws\_region) | Assuming single region for now. | `string` | n/a | yes | | [network\_configs](#input\_network\_configs) | n/a |
object({
vpc_id = string
subnet_ids = list(string)
lambda_vpc_id = string
lambda_subnet_ids = list(string)
})
| n/a | yes | -| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = string
ghes_org = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_group_name = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = optional(object({
ami_filter = object({
name = list(string)
state = list(string)
})
ami_kms_key_arn = string
ami_owners = list(string)
instance_types = list(string)
license_specifications = optional(list(object({
license_configuration_arn = string
})), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
use_dedicated_host = optional(bool, false)
enable_userdata = bool
instance_target_capacity_type = string
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
scale_errors = optional(list(string), [])
block_device_mappings = list(object({
delete_on_termination = bool
device_name = string
encrypted = bool
iops = number
kms_key_id = string
snapshot_id = string
throughput = number
volume_initialization_rate = optional(number)
volume_size = number
volume_type = string
}))
}), null)
microvm = optional(object({
image_identifier = string
image_version = optional(string, null)
egress_network_connectors = optional(list(string), [])
idle_policy = optional(object({
max_idle_duration_seconds = number
suspended_duration_seconds = number
auto_resume_enabled = bool
}), null)
logging = optional(object({
cloud_watch = optional(object({
log_group = optional(string, null)
log_stream = optional(string, null)
}), null)
disabled = optional(bool, false)
}), null)
run_hook_payload = optional(string, null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
tags = optional(map(string), {})
iam = optional(object({
resource_arns = optional(list(string), ["*"])
actions = optional(object({
scale_up = optional(list(string), null)
scale_down = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policy_arns = optional(object({
scale_up = optional(string, null)
pool = optional(string, null)
}), {})
}), {})
}), null)
})
}))
})
| n/a | yes | +| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = string
ghes_org = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_group_name = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_identifier = string
image_version = optional(string, null)
egress_network_connectors = optional(list(string), [])
idle_policy = optional(object({
max_idle_duration_seconds = number
suspended_duration_seconds = number
auto_resume_enabled = bool
}), null)
logging = optional(object({
cloud_watch = optional(object({
log_group = optional(string, null)
log_stream = optional(string, null)
}), null)
disabled = optional(bool, false)
}), null)
run_hook_payload = optional(string, null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
tags = optional(map(string), {})
iam = optional(object({
resource_arns = optional(list(string), ["*"])
actions = optional(object({
scale_up = optional(list(string), null)
scale_down = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policy_arns = optional(object({
scale_up = optional(string, null)
pool = optional(string, null)
}), {})
}), {})
}), null)
})
}))
})
| n/a | yes | | [tenant\_configs](#input\_tenant\_configs) | n/a |
object({
ecr_registries = list(string)
tags = map(string)
})
| n/a | yes | ## Outputs diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf index 79994bf16..43e99f51f 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf +++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf @@ -12,6 +12,16 @@ locals { for key, name in local.runner_ami_ssm_parameter_names : key => "arn:${data.aws_partition.current.partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${name}" } + + ec2_update_runner_ami_map = { + for key, runner_config in local.ec2_runner_configs : + key => { + resource_ssm_id = local.runner_ami_ssm_parameter_arns[key] + ssm_id = local.runner_ami_ssm_parameter_names[key] + ami_filter = local.ec2_compute_provider[key].ami.filter + ami_owners = local.ec2_compute_provider[key].ami.owners + } + } } module "ec2_update_runner_ssm_ami" { @@ -27,14 +37,6 @@ module "ec2_update_runner_ssm_ami" { log_level = var.runner_configs.log_level tags = var.tenant_configs.tags - runner_ami_map = { - for key, runner_config in local.ec2_runner_configs : - key => { - resource_ssm_id = local.runner_ami_ssm_parameter_arns[key] - ssm_id = local.runner_ami_ssm_parameter_names[key] - ami_filter = runner_config.compute_provider.ec2.ami_filter - ami_owners = runner_config.compute_provider.ec2.ami_owners - } - } + runner_ami_map = local.ec2_update_runner_ami_map } diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/README.md b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/README.md index 6f5b2908e..b67ae98ad 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/README.md +++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/README.md @@ -56,7 +56,7 @@ Forge runner AMIs are updated as images are rebuilt and tested. This helper keep | [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | `"INFO"` | no | | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Retention in days for CloudWatch Log Group for the Lambdas. | `number` | `30` | no | | [prefix](#input\_prefix) | Prefix for all resources | `string` | n/a | yes | -| [runner\_ami\_map](#input\_runner\_ami\_map) | n/a |
map(object({
resource_ssm_id = string
ssm_id = string
ami_filter = object({
name = list(string)
state = list(string)
})
ami_owners = list(string)
}))
| n/a | yes | +| [runner\_ami\_map](#input\_runner\_ami\_map) | n/a |
map(object({
resource_ssm_id = string
ssm_id = string
ami_filter = map(list(string))
ami_owners = list(string)
}))
| n/a | yes | | [tags](#input\_tags) | Tags to apply to created resources. | `map(string)` | `{}` | no | ## Outputs diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/tests/interface_contract.tftest.hcl index 33655a486..1285d8266 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/tests/interface_contract.tftest.hcl @@ -30,10 +30,8 @@ run "platform_ec2_deployment_ec2_update_runner_ssm_ami_interface_contract" { "type = map(object({", "resource_ssm_id = string", "ssm_id = string", - "ami_filter = object({", - "name = list(string)", - "state = list(string)", - "ami_owners = list(string)", + "ami_filter = map(list(string))", + "ami_owners = list(string)", "}))", "variable \"tags\"", "description = \"Tags to apply to created resources.\"", @@ -71,7 +69,7 @@ run "platform_ec2_deployment_ec2_update_runner_ssm_ami_interface_contract" { condition = ( output.expected_input_variable_count == 5 && output.expected_output_value_count == 0 - && output.expected_interface_literal_count == 23 + && output.expected_interface_literal_count == 21 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/variables.tf b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/variables.tf index 1654e1de8..4277b8d52 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/variables.tf +++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/variables.tf @@ -25,10 +25,7 @@ variable "runner_ami_map" { type = map(object({ resource_ssm_id = string ssm_id = string - ami_filter = object({ - name = list(string) - state = list(string) - }) - ami_owners = list(string) + ami_filter = map(list(string)) + ami_owners = list(string) })) } diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf index 8c76a2769..ac2faf3b0 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -23,6 +23,18 @@ locals { runner_config.compute_provider.ec2.subnet_ids == null ? var.network_configs.subnet_ids : runner_config.compute_provider.ec2.subnet_ids ])) + # This is the upstream EC2 provider's default AMI selection. Normalize it + # here so the provider and Forge's scheduled AMI refresh use one effective + # filter map. + ec2_default_ami_filters = { + for key, runner_config in local.ec2_runner_configs : + key => ({ + windows = { name = ["Windows_Server-2022-English-Full-ECS_Optimized-*"] } + linux = runner_config.runner_architecture == "arm64" ? { name = ["al2023-ami-2023.*-kernel-6.*-arm64"] } : { name = ["al2023-ami-2023.*-kernel-6.*-x86_64"] } + osx = runner_config.runner_architecture == "arm64" ? { name = ["amzn-ec2-macos-15.*-arm64"] } : { name = ["amzn-ec2-macos-15.*"] } + })[runner_config.runner_os] + } + runner_labels = { for key, runner_config in var.runner_configs.runner_specs : key => concat(runner_config.runner_labels, runner_config.extra_labels) @@ -33,84 +45,77 @@ locals { "forge-${policy_index}" => policy_arn } + forge_ec2_log_files = { + for key, runner_config in local.ec2_runner_configs : + key => concat( + runner_config.runner_os == "windows" ? [] : [ + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = "/var/log/syslog" + log_stream_name = "{instance_id}/syslog" + }, + ], + [ + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = runner_config.runner_os == "windows" ? "C:/UserData.log" : "/var/log/user-data.log" + log_stream_name = "{instance_id}/user-data" + }, + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = runner_config.runner_os == "windows" ? "C:/actions-runner/_diag/Runner_*.log" : "/opt/actions-runner/_diag/Runner_**.log" + log_stream_name = "{instance_id}/runner" + }, + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = runner_config.runner_os == "windows" ? "C:/Users/Administrator/AppData/Local/Temp/hook_*.log" : "/home/${runner_config.runner_user}/hook.log" + log_stream_name = "{instance_id}/hook" + }, + ], + ) + } + ec2_compute_provider = { for key, runner_config in local.ec2_runner_configs : - key => { - metadata_options = { - http_endpoint = "enabled" - http_put_response_hop_limit = 2 - http_tokens = "optional" - instance_metadata_tags = "enabled" - } - ami = { - owners = runner_config.compute_provider.ec2.ami_owners - filter = runner_config.compute_provider.ec2.ami_filter - kms_key = runner_config.compute_provider.ec2.ami_kms_key_arn == null ? null : { - arn = runner_config.compute_provider.ec2.ami_kms_key_arn - } - } - block_device_mappings = runner_config.compute_provider.ec2.block_device_mappings - create_service_linked_role_spot = true - cloudwatch_agent = { - enabled = true - } - binaries_syncer = { - enabled = false - } - detailed_monitoring_enabled = true - ssm_enabled = true - user_data = { - enabled = runner_config.compute_provider.ec2.enable_userdata - template = "${local.user_data_prefix}/user_data_${runner_config.runner_os}.tftpl" - pre_install = "# No pre-install steps." - post_install = templatefile( - local.userdata_template_post_install, + key => merge( + runner_config.compute_provider.ec2, + { + ami = runner_config.compute_provider.ec2.ami == null ? null : merge( + runner_config.compute_provider.ec2.ami, { - runner_user = runner_config.runner_user - ecr_registries = var.tenant_configs.ecr_registries + filter = merge( + local.ec2_default_ami_filters[key], + runner_config.compute_provider.ec2.ami.filter, + ) } ) - } - instance_target_capacity_type = runner_config.compute_provider.ec2.instance_target_capacity_type - instance_types = runner_config.compute_provider.ec2.instance_types - scale_errors = runner_config.compute_provider.ec2.scale_errors - vpc_id = runner_config.compute_provider.ec2.vpc_id - subnet_ids = runner_config.compute_provider.ec2.subnet_ids - placement = runner_config.compute_provider.ec2.placement - license_specifications = runner_config.compute_provider.ec2.license_specifications - use_dedicated_host = runner_config.compute_provider.ec2.use_dedicated_host - log_files = concat( - runner_config.runner_os == "windows" ? [] : [ - { - log_group_name = "forge-logs" - prefix_log_group = true - file_path = "/var/log/syslog" - log_stream_name = "{instance_id}/syslog" - }, - ], - [ - { - log_group_name = "forge-logs" - prefix_log_group = true - file_path = runner_config.runner_os == "windows" ? "C:/UserData.log" : "/var/log/user-data.log" - log_stream_name = "{instance_id}/user-data" - }, + user_data = merge( + runner_config.compute_provider.ec2.user_data, { - log_group_name = "forge-logs" - prefix_log_group = true - file_path = runner_config.runner_os == "windows" ? "C:/actions-runner/_diag/Runner_*.log" : "/opt/actions-runner/_diag/Runner_**.log" - log_stream_name = "{instance_id}/runner" - }, - { - log_group_name = "forge-logs" - prefix_log_group = true - file_path = runner_config.runner_os == "windows" ? "C:/Users/Administrator/AppData/Local/Temp/hook_*.log" : "/home/${runner_config.runner_user}/hook.log" - log_stream_name = "{instance_id}/hook" - }, - ], - ) - tags = var.tenant_configs.tags - } + template = ( + runner_config.compute_provider.ec2.user_data.content == null + && runner_config.compute_provider.ec2.user_data.template == null + ) ? "${local.user_data_prefix}/user_data_${runner_config.runner_os}.tftpl" : runner_config.compute_provider.ec2.user_data.template + post_install = join("\n", compact([ + runner_config.compute_provider.ec2.user_data.post_install, + templatefile( + local.userdata_template_post_install, + { + runner_user = runner_config.runner_user + ecr_registries = var.tenant_configs.ecr_registries + } + ), + ])) + } + ) + log_files = coalesce(runner_config.compute_provider.ec2.log_files, local.forge_ec2_log_files[key]) + tags = merge(var.tenant_configs.tags, runner_config.compute_provider.ec2.tags) + } + ) } multi_runner_config_v2 = { diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index 8872b6412..e747c191e 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -138,16 +138,20 @@ variables { runner_user = "ec2-user" compute_provider = { ec2 = { - ami_filter = { - name = ["forge-*"] - state = ["available"] + ami = { + filter = { + name = ["forge-*"] + state = ["available"] + } + owners = ["123456789012"] } - ami_kms_key_arn = null - ami_owners = ["123456789012"] + ebs_optimized = true instance_types = ["m7i.large"] - enable_userdata = true instance_target_capacity_type = "on-demand" subnet_ids = ["subnet-override"] + user_data = { + enabled = true + } block_device_mappings = [{ delete_on_termination = true device_name = "/dev/xvda" @@ -193,17 +197,26 @@ run "mixed_provider_plan" { } assert { - condition = ( - toset(keys(local.ec2_runner_configs)) == toset(["ec2"]) - && toset(keys(local.multi_runner_config_v2)) == toset(["ec2", "microvm"]) - && local.active_ec2_subnet_ids == toset(["subnet-override"]) - ) - error_message = "Provider filtering and EC2 effective subnet resolution must preserve both provider lanes." + condition = toset(keys(local.ec2_runner_configs)) == toset(["ec2"]) + error_message = "EC2 provider filtering must retain every EC2 lane." + } + + assert { + condition = toset(keys(local.multi_runner_config_v2)) == toset(["ec2", "microvm"]) + error_message = "The upstream v2 map must preserve all provider lane keys." + } + + assert { + condition = local.active_ec2_subnet_ids == toset(["subnet-override"]) + error_message = "EC2 effective subnet resolution must preserve per-lane overrides." } assert { condition = ( local.multi_runner_config_v2.ec2.compute_provider.ec2 != null + && tolist(local.multi_runner_config_v2.ec2.compute_provider.ec2.ami.filter.name) == tolist(["forge-*"]) + && local.multi_runner_config_v2.ec2.compute_provider.ec2.ami.id_ssm_parameter == null + && local.multi_runner_config_v2.ec2.compute_provider.ec2.ebs_optimized && local.multi_runner_config_v2.ec2.compute_provider.microvm == null && local.multi_runner_config_v2.microvm.compute_provider.ec2 == null && local.multi_runner_config_v2.microvm.compute_provider.microvm.image_identifier == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:test" @@ -211,6 +224,15 @@ run "mixed_provider_plan" { error_message = "The v2 translation must select exactly one configured provider per lane." } + assert { + condition = ( + tolist(local.ec2_default_ami_filters.ec2.name) == tolist(["al2023-ami-2023.*-kernel-6.*-x86_64"]) + && tolist(local.ec2_compute_provider.ec2.ami.filter.name) == tolist(["forge-*"]) + && tolist(local.ec2_compute_provider.ec2.ami.filter.state) == tolist(["available"]) + ) + error_message = "The scheduled AMI refresh must merge upstream defaults with caller filters." + } + assert { condition = ( length(local.multi_runner_config_v2.microvm.matcherConfig.labelMatchers) == 1 diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index 0a8216095..2b4838c35 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -76,14 +76,60 @@ run "platform_ec2_deployment_interface_contract" { "runner_user = string", "compute_provider = object({", "ec2 = optional(object({", - "ami_filter = object({", - "name = list(string)", - "state = list(string)", - "ami_kms_key_arn = string", - "ami_owners = list(string)", - "instance_types = list(string)", - "license_specifications = optional(list(object({", - "license_configuration_arn = string", + "metadata_options = optional(object({", + "instance_metadata_tags = optional(string, \"enabled\")", + "http_endpoint = optional(string, \"enabled\")", + "http_tokens = optional(string, \"required\")", + "http_put_response_hop_limit = optional(number, 1)", + "ami = optional(object({", + "filter = optional(map(list(string)), { state = [\"available\"] })", + "owners = optional(list(string), [\"amazon\"])", + "id_ssm_parameter = optional(object({", + "kms_key = optional(object({", + "arn = string", + "block_device_mappings = optional(list(object({", + "delete_on_termination = optional(bool, true)", + "device_name = optional(string, \"/dev/xvda\")", + "encrypted = optional(bool, true)", + "iops = optional(number)", + "kms_key_id = optional(string)", + "snapshot_id = optional(string)", + "throughput = optional(number)", + "volume_initialization_rate = optional(number)", + "volume_size = number", + "volume_type = optional(string, \"gp3\")", + "create_service_linked_role_spot = optional(bool, false)", + "credit_specification = optional(string, null)", + "ebs_optimized = optional(bool, false)", + "cloudwatch_agent = optional(object({", + "config = optional(string, null)", + "binaries_syncer = optional(object({", + "detailed_monitoring_enabled = optional(bool, false)", + "ssm_enabled = optional(bool, false)", + "user_data = optional(object({", + "enabled = optional(bool, true)", + "template = optional(string, null)", + "content = optional(string, null)", + "pre_install = optional(string, \"\")", + "post_install = optional(string, \"\")", + "debug_logging_enabled = optional(bool, false)", + "instance_allocation_strategy = optional(string, \"lowest-price\")", + "instance_max_spot_price = optional(string, null)", + "instance_target_capacity_type = optional(string, \"spot\")", + "instance_type_priorities = optional(map(number), null)", + "instance_types = list(string)", + "additional_security_group_ids = optional(list(string), [])", + "instance_profile = optional(object({", + "name = string", + "enable_on_demand_failover_for_errors = optional(list(string), [])", + "scale_errors = optional(list(string), [", + "subnet_ids = optional(list(string), null)", + "vpc_id = optional(string, null)", + "cpu_options = optional(object({", + "core_count = optional(number)", + "threads_per_core = optional(number)", + "amd_sev_snp = optional(string)", + "nested_virtualization = optional(string)", "placement = optional(object({", "affinity = optional(string)", "availability_zone = optional(string)", @@ -94,23 +140,16 @@ run "platform_ec2_deployment_interface_contract" { "spread_domain = optional(string)", "tenancy = optional(string)", "partition_number = optional(number)", - "use_dedicated_host = optional(bool, false)", - "enable_userdata = bool", - "instance_target_capacity_type = string", - "vpc_id = optional(string, null)", - "subnet_ids = optional(list(string), null)", - "scale_errors = optional(list(string), [])", - "block_device_mappings = list(object({", - "delete_on_termination = bool", - "device_name = string", - "encrypted = bool", - "iops = number", - "kms_key_id = string", - "snapshot_id = string", - "throughput = number", - "volume_initialization_rate = optional(number)", - "volume_size = number", - "volume_type = string", + "license_specifications = optional(list(object({", + "license_configuration_arn = string", + "use_dedicated_host = optional(bool, false)", + "log_files = optional(list(object({", + "log_group_name = string", + "prefix_log_group = bool", + "file_path = string", + "log_stream_name = string", + "log_class = optional(string, \"STANDARD\")", + "tags = optional(map(string), {})", "microvm = optional(object({", "image_identifier = string", "image_version = optional(string, null)", @@ -143,8 +182,27 @@ run "platform_ec2_deployment_interface_contract" { "tags = map(string)", "if runner_config.compute_provider.ec2 != null", "if runner_config.compute_provider.microvm != null", + "length(runner_config.compute_provider.ec2.ami[*]) == 1", + "&& try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false)", + "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"", + "length(runner_config.compute_provider.ec2.instance_profile[*]) == 0", + "error_message = \"Forge EC2 runner_specs do not support an external instance_profile.\"", "active_ec2_subnet_ids = toset(flatten([", - "scale_errors = runner_config.compute_provider.ec2.scale_errors", + "ec2_default_ami_filters = {", + "windows = { name = [\"Windows_Server-2022-English-Full-ECS_Optimized-*\"] }", + "linux = runner_config.runner_architecture == \"arm64\" ? { name = [\"al2023-ami-2023.*-kernel-6.*-arm64\"] } : { name = [\"al2023-ami-2023.*-kernel-6.*-x86_64\"] }", + "osx = runner_config.runner_architecture == \"arm64\" ? { name = [\"amzn-ec2-macos-15.*-arm64\"] } : { name = [\"amzn-ec2-macos-15.*\"] }", + "ec2_update_runner_ami_map = {", + "local.ec2_default_ami_filters[key],", + "ami = runner_config.compute_provider.ec2.ami == null ? null : merge(", + "runner_config.compute_provider.ec2.ami.filter,", + "ami_filter = local.ec2_compute_provider[key].ami.filter", + "ami_owners = local.ec2_compute_provider[key].ami.owners", + "runner_ami_map = local.ec2_update_runner_ami_map", + "key => merge(", + "runner_config.compute_provider.ec2,", + "log_files = coalesce(runner_config.compute_provider.ec2.log_files, local.forge_ec2_log_files[key])", + "tags = merge(var.tenant_configs.tags, runner_config.compute_provider.ec2.tags)", "managed_policy_arns = merge(", "local.runner_iam_role_managed_policy_arns,", "forge_ec2_tags = aws_iam_policy.ec2_tags[0].arn", @@ -205,7 +263,7 @@ run "platform_ec2_deployment_interface_contract" { condition = ( output.expected_input_variable_count == 4 && output.expected_output_value_count == 12 - && output.expected_interface_literal_count == 145 + && output.expected_interface_literal_count == 203 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index 626935e23..3a4cddf5f 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -48,16 +48,85 @@ variable "runner_configs" { runner_user = string compute_provider = object({ ec2 = optional(object({ - ami_filter = object({ - name = list(string) - state = list(string) - }) - ami_kms_key_arn = string - ami_owners = list(string) - instance_types = list(string) - license_specifications = optional(list(object({ - license_configuration_arn = string - })), null) + metadata_options = optional(object({ + instance_metadata_tags = optional(string, "enabled") + http_endpoint = optional(string, "enabled") + http_tokens = optional(string, "required") + http_put_response_hop_limit = optional(number, 1) + }), {}) + ami = optional(object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter = optional(object({ + arn = string + }), null) + kms_key = optional(object({ + arn = string + }), null) + }), null) + block_device_mappings = optional(list(object({ + delete_on_termination = optional(bool, true) + device_name = optional(string, "/dev/xvda") + encrypted = optional(bool, true) + iops = optional(number) + kms_key_id = optional(string) + snapshot_id = optional(string) + throughput = optional(number) + volume_initialization_rate = optional(number) + volume_size = number + volume_type = optional(string, "gp3") + })), [{ + volume_size = 30 + }]) + create_service_linked_role_spot = optional(bool, false) + credit_specification = optional(string, null) + ebs_optimized = optional(bool, false) + cloudwatch_agent = optional(object({ + enabled = optional(bool, true) + config = optional(string, null) + }), {}) + binaries_syncer = optional(object({ + enabled = optional(bool, true) + }), {}) + detailed_monitoring_enabled = optional(bool, false) + ssm_enabled = optional(bool, false) + user_data = optional(object({ + enabled = optional(bool, true) + template = optional(string, null) + content = optional(string, null) + pre_install = optional(string, "") + post_install = optional(string, "") + debug_logging_enabled = optional(bool, false) + }), {}) + instance_allocation_strategy = optional(string, "lowest-price") + instance_max_spot_price = optional(string, null) + instance_target_capacity_type = optional(string, "spot") + instance_type_priorities = optional(map(number), null) + instance_types = list(string) + additional_security_group_ids = optional(list(string), []) + instance_profile = optional(object({ + name = string + }), null) + enable_on_demand_failover_for_errors = optional(list(string), []) + scale_errors = optional(list(string), [ + "UnfulfillableCapacity", + "MaxSpotInstanceCountExceeded", + "TargetCapacityLimitExceededException", + "RequestLimitExceeded", + "ResourceLimitExceeded", + "MaxSpotInstanceCountExceeded", + "MaxSpotFleetRequestCountExceeded", + "InsufficientInstanceCapacity", + "InsufficientCapacityOnHost", + ]) + subnet_ids = optional(list(string), null) + vpc_id = optional(string, null) + cpu_options = optional(object({ + core_count = optional(number) + threads_per_core = optional(number) + amd_sev_snp = optional(string) + nested_virtualization = optional(string) + }), null) placement = optional(object({ affinity = optional(string) availability_zone = optional(string) @@ -69,24 +138,18 @@ variable "runner_configs" { tenancy = optional(string) partition_number = optional(number) }), null) - use_dedicated_host = optional(bool, false) - enable_userdata = bool - instance_target_capacity_type = string - vpc_id = optional(string, null) - subnet_ids = optional(list(string), null) - scale_errors = optional(list(string), []) - block_device_mappings = list(object({ - delete_on_termination = bool - device_name = string - encrypted = bool - iops = number - kms_key_id = string - snapshot_id = string - throughput = number - volume_initialization_rate = optional(number) - volume_size = number - volume_type = string - })) + license_specifications = optional(list(object({ + license_configuration_arn = string + })), []) + use_dedicated_host = optional(bool, false) + log_files = optional(list(object({ + log_group_name = string + prefix_log_group = bool + file_path = string + log_stream_name = string + log_class = optional(string, "STANDARD") + })), null) + tags = optional(map(string), {}) }), null) microvm = optional(object({ image_identifier = string @@ -137,6 +200,25 @@ variable "runner_configs" { ]) error_message = "Each runner_specs entry must configure exactly one compute provider: ec2 or microvm." } + + validation { + condition = alltrue([ + for runner_config in values(var.runner_configs.runner_specs) : + runner_config.compute_provider.ec2 == null ? true : ( + length(runner_config.compute_provider.ec2.ami[*]) == 1 + && try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false) + ) + ]) + error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." + } + + validation { + condition = alltrue([ + for runner_config in values(var.runner_configs.runner_specs) : + runner_config.compute_provider.ec2 == null ? true : length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 + ]) + error_message = "Forge EC2 runner_specs do not support an external instance_profile." + } } variable "network_configs" { diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index aa92d9ac5..d8bad2e9b 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -118,7 +118,7 @@ tag with the expected ref in `release_versions.yml`. | [aws\_region](#input\_aws\_region) | AWS region where Forge runners and supporting infrastructure are deployed. | `string` | n/a | yes | | [default\_tags](#input\_default\_tags) | A map of tags to apply to resources. | `map(string)` | n/a | yes | | [deployment\_config](#input\_deployment\_config) | High-level deployment configuration for a Forge runner installation.

Top-level fields:
- deployment\_prefix: Prefix used when naming resources (for example,
log groups, KMS keys, and SSM parameters).
- env : Logical environment name (for example, dev, stage,
prod). Used for tagging and dashboards.

github\_app object:
- id : Numeric GitHub App ID.
- client\_id : OAuth client ID for the app.
- installation\_id: GitHub App installation ID for this tenant.
- name : GitHub App name, used to build URLs and logs.

github object:
- ghes\_org : GitHub organization that owns the repos where
runners will be used.
- ghes\_url : GitHub.com or GHES base URL. Empty string implies
public github.com.
- repository\_selection: Scope for runners (all or selected repositories).
- runner\_group\_name : GitHub runner group to attach new runners to.

tenant object:
- name : Tenant identifier used in naming and
tagging.
- iam\_roles\_to\_assume : Optional list of IAM role ARNs that
runners are allowed to assume for workload execution.
- ecr\_registries : Optional list of ECR registry URLs that
runners may need to pull images from.
- github\_logs\_reader\_role\_arns: Optional list of IAM roles that can read
GitHub Actions logs for this tenant. |
object({
deployment_prefix = string
secret_suffix = string
env = string
github_app = object({
id = string
client_id = string
installation_id = string
name = string
})
github = object({
ghes_org = string
ghes_url = string
repository_selection = string
runner_group_name = string
})
tenant = object({
name = string
iam_roles_to_assume = optional(list(string), [])
ecr_registries = optional(list(string), [])
github_logs_reader_role_arns = optional(list(string), [])
})
})
| n/a | yes | -| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | Compute deployment configuration for GitHub Actions runners.

Top-level fields:
- lambda\_subnet\_ids: Subnets where runner-related lambdas execute.
These can be more permissive than the runner subnets.
- subnet\_ids : Default subnets for compute providers that use the VPC.
- vpc\_id : VPC that contains both runner and lambda subnets.
- runner\_specs : Map of provider-aware runner lanes.

runner\_specs[*] object fields:
- runner\_labels : Base GitHub labels applied to jobs for this pool.
- runner\_os : Runner operating system (for example, linux).
- runner\_architecture: CPU architecture (for example, x86\_64 or arm64).
- extra\_labels : Additional GitHub labels that further specialize
this runner pool.
- enable\_dynamic\_labels: Enables dynamic `ghr-` labels for this runner
pool.
- aws\_dynamic\_labels\_policy: Optional policy for `ghr-ec2-*` labels for
this runner pool.
- lambda\_event\_source\_mapping\_batch\_size: Optional maximum number of queued
jobs passed to the scale-up Lambda per invocation.
- lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: Optional
maximum time to collect queued jobs before invoking the
scale-up Lambda.
- redrive\_build\_queue: Optional dead-letter queue redrive configuration.
Controls whether redrive is enabled and how many times a
message can be received before moving to the dead-letter
queue.
- max\_instances : Maximum number of runners in this pool.
- min\_run\_time : Minimum job run time (in minutes) before a runner
is eligible for scale-down.
- pool\_config : List of pool size schedules (size + cron expression
and optional time zone) controlling baseline capacity.
- runner\_user : OS user under which the GitHub runner process runs.
- compute\_provider: Exactly one typed provider block: ec2 or microvm.

compute\_provider.ec2 fields:
- ami\_filter : Name/state filters used to select the runner AMI.
- ami\_kms\_key\_arn : KMS key ARN used to encrypt AMI EBS volumes.
- ami\_owners : List of AWS account IDs that own the AMI.
- instance\_types : Allowed EC2 instance types for runners in this pool.
- placement : Optional EC2 placement configuration.
- license\_specifications: Optional EC2 License Manager configuration ARNs.
- use\_dedicated\_host: Whether this runner pool should use dedicated hosts.
- enable\_userdata : Whether to inject the standard runner user data.
- instance\_target\_capacity\_type: EC2 capacity type (spot or on-demand).
- vpc\_id/subnet\_ids: Optional per-lane network overrides.
- block\_device\_mappings: EBS volume configuration for runner instances.
- scale\_errors : Retryable EC2 scale-up error codes.

compute\_provider.microvm fields:
- image\_identifier: ARN or ID of the Lambda MicroVM image.
- image\_version : Optional Lambda MicroVM image version.
- egress\_network\_connectors: Optional Lambda MicroVM network connectors.
- idle\_policy/logging/run\_hook\_payload: Optional runtime behavior.
- maximum\_duration\_in\_seconds: Optional maximum MicroVM lifetime.
- environment\_variables/tags: Provider-specific runtime configuration.
- iam : Optional MicroVM control-plane IAM overrides. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = optional(object({
ami_filter = object({
name = list(string)
state = list(string)
})
ami_kms_key_arn = string
ami_owners = list(string)
instance_types = list(string)
license_specifications = optional(list(object({
license_configuration_arn = string
})), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
use_dedicated_host = optional(bool, false)
enable_userdata = bool
instance_target_capacity_type = string
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
scale_errors = optional(list(string), [])
block_device_mappings = list(object({
delete_on_termination = bool
device_name = string
encrypted = bool
iops = number
kms_key_id = string
snapshot_id = string
throughput = number
volume_initialization_rate = optional(number)
volume_size = number
volume_type = string
}))
}), null)
microvm = optional(object({
image_identifier = string
image_version = optional(string, null)
egress_network_connectors = optional(list(string), [])
idle_policy = optional(object({
max_idle_duration_seconds = number
suspended_duration_seconds = number
auto_resume_enabled = bool
}), null)
logging = optional(object({
cloud_watch = optional(object({
log_group = optional(string, null)
log_stream = optional(string, null)
}), null)
disabled = optional(bool, false)
}), null)
run_hook_payload = optional(string, null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
tags = optional(map(string), {})
iam = optional(object({
resource_arns = optional(list(string), ["*"])
actions = optional(object({
scale_up = optional(list(string), null)
scale_down = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policy_arns = optional(object({
scale_up = optional(string, null)
pool = optional(string, null)
}), {})
}), {})
}), null)
})
}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | Compute deployment configuration for GitHub Actions runners.

Top-level fields:
- lambda\_subnet\_ids: Subnets where runner-related lambdas execute.
These can be more permissive than the runner subnets.
- subnet\_ids : Default subnets for compute providers that use the VPC.
- vpc\_id : VPC that contains both runner and lambda subnets.
- runner\_specs : Map of provider-aware runner lanes.

runner\_specs[*] object fields:
- runner\_labels : Base GitHub labels applied to jobs for this pool.
- runner\_os : Runner operating system (for example, linux).
- runner\_architecture: CPU architecture (for example, x86\_64 or arm64).
- extra\_labels : Additional GitHub labels that further specialize
this runner pool.
- enable\_dynamic\_labels: Enables dynamic `ghr-` labels for this runner
pool.
- aws\_dynamic\_labels\_policy: Optional policy for `ghr-ec2-*` labels for
this runner pool.
- lambda\_event\_source\_mapping\_batch\_size: Optional maximum number of queued
jobs passed to the scale-up Lambda per invocation.
- lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: Optional
maximum time to collect queued jobs before invoking the
scale-up Lambda.
- redrive\_build\_queue: Optional dead-letter queue redrive configuration.
Controls whether redrive is enabled and how many times a
message can be received before moving to the dead-letter
queue.
- max\_instances : Maximum number of runners in this pool.
- min\_run\_time : Minimum job run time (in minutes) before a runner
is eligible for scale-down.
- pool\_config : List of pool size schedules (size + cron expression
and optional time zone) controlling baseline capacity.
- runner\_user : OS user under which the GitHub runner process runs.
- compute\_provider: Exactly one typed provider block: ec2 or microvm.

compute\_provider.ec2 fields:
- ami : Upstream-compatible EC2 AMI configuration.
Forge requires a module-managed AMI block; null and
external AMI parameter ownership are unsupported.
- metadata\_options: EC2 instance metadata service configuration.
- block\_device\_mappings: EBS mappings for runner instances.
- cloudwatch\_agent/binaries\_syncer/user\_data: Runner bootstrap configuration.
- instance\_types and allocation fields: EC2 Fleet capacity configuration.
- vpc\_id/subnet\_ids/additional\_security\_group\_ids: Per-lane networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Upstream contract field reserved for future Forge support.
- log\_files/tags : Provider-specific logging and resource tags.

compute\_provider.microvm fields:
- image\_identifier: ARN or ID of the Lambda MicroVM image.
- image\_version : Optional Lambda MicroVM image version.
- egress\_network\_connectors: Optional Lambda MicroVM network connectors.
- idle\_policy/logging/run\_hook\_payload: Optional runtime behavior.
- maximum\_duration\_in\_seconds: Optional maximum MicroVM lifetime.
- environment\_variables/tags: Provider-specific runtime configuration.
- iam : Optional MicroVM control-plane IAM overrides. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_identifier = string
image_version = optional(string, null)
egress_network_connectors = optional(list(string), [])
idle_policy = optional(object({
max_idle_duration_seconds = number
suspended_duration_seconds = number
auto_resume_enabled = bool
}), null)
logging = optional(object({
cloud_watch = optional(object({
log_group = optional(string, null)
log_stream = optional(string, null)
}), null)
disabled = optional(bool, false)
}), null)
run_hook_payload = optional(string, null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
tags = optional(map(string), {})
iam = optional(object({
resource_arns = optional(list(string), ["*"])
actions = optional(object({
scale_up = optional(list(string), null)
scale_down = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policy_arns = optional(object({
scale_up = optional(string, null)
pool = optional(string, null)
}), {})
}), {})
}), null)
})
}))
})
| n/a | yes | | [github\_webhook\_relay](#input\_github\_webhook\_relay) | Configuration for the (optional) webhook relay source module.
If enabled=true we provision the API Gateway + source EventBridge forwarding rule.
destination\_event\_bus\_name must already exist or be created in the destination account (or via the destination submodule run there). |
object({
enabled = bool
destination_account_id = optional(string)
destination_event_bus_name = optional(string)
destination_region = optional(string)
destination_reader_role_arn = optional(string)
})
|
{
"destination_account_id": "",
"destination_event_bus_name": "",
"destination_reader_role_arn": "",
"destination_region": "",
"enabled": false
}
| no | | [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | n/a | yes | | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Logging retention period in days. | `string` | n/a | yes | diff --git a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl index 362cc84ea..e2639d67b 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -163,14 +163,60 @@ run "platform_forge_runners_interface_contract" { "runner_user = string", "compute_provider = object({", "ec2 = optional(object({", - "ami_filter = object({", - "name = list(string)", - "state = list(string)", - "ami_kms_key_arn = string", - "ami_owners = list(string)", - "instance_types = list(string)", - "license_specifications = optional(list(object({", - "license_configuration_arn = string", + "metadata_options = optional(object({", + "instance_metadata_tags = optional(string, \"enabled\")", + "http_endpoint = optional(string, \"enabled\")", + "http_tokens = optional(string, \"required\")", + "http_put_response_hop_limit = optional(number, 1)", + "ami = optional(object({", + "filter = optional(map(list(string)), { state = [\"available\"] })", + "owners = optional(list(string), [\"amazon\"])", + "id_ssm_parameter = optional(object({", + "kms_key = optional(object({", + "arn = string", + "block_device_mappings = optional(list(object({", + "delete_on_termination = optional(bool, true)", + "device_name = optional(string, \"/dev/xvda\")", + "encrypted = optional(bool, true)", + "iops = optional(number)", + "kms_key_id = optional(string)", + "snapshot_id = optional(string)", + "throughput = optional(number)", + "volume_initialization_rate = optional(number)", + "volume_size = number", + "volume_type = optional(string, \"gp3\")", + "create_service_linked_role_spot = optional(bool, false)", + "credit_specification = optional(string, null)", + "ebs_optimized = optional(bool, false)", + "cloudwatch_agent = optional(object({", + "config = optional(string, null)", + "binaries_syncer = optional(object({", + "detailed_monitoring_enabled = optional(bool, false)", + "ssm_enabled = optional(bool, false)", + "user_data = optional(object({", + "enabled = optional(bool, true)", + "template = optional(string, null)", + "content = optional(string, null)", + "pre_install = optional(string, \"\")", + "post_install = optional(string, \"\")", + "debug_logging_enabled = optional(bool, false)", + "instance_allocation_strategy = optional(string, \"lowest-price\")", + "instance_max_spot_price = optional(string, null)", + "instance_target_capacity_type = optional(string, \"spot\")", + "instance_type_priorities = optional(map(number), null)", + "instance_types = list(string)", + "additional_security_group_ids = optional(list(string), [])", + "instance_profile = optional(object({", + "name = string", + "enable_on_demand_failover_for_errors = optional(list(string), [])", + "scale_errors = optional(list(string), [", + "subnet_ids = optional(list(string), null)", + "vpc_id = optional(string, null)", + "cpu_options = optional(object({", + "core_count = optional(number)", + "threads_per_core = optional(number)", + "amd_sev_snp = optional(string)", + "nested_virtualization = optional(string)", "placement = optional(object({", "affinity = optional(string)", "availability_zone = optional(string)", @@ -181,23 +227,16 @@ run "platform_forge_runners_interface_contract" { "spread_domain = optional(string)", "tenancy = optional(string)", "partition_number = optional(number)", - "use_dedicated_host = optional(bool, false)", - "enable_userdata = bool", - "instance_target_capacity_type = string", - "vpc_id = optional(string, null)", - "subnet_ids = optional(list(string), null)", - "block_device_mappings = list(object({", - "delete_on_termination = bool", - "device_name = string", - "encrypted = bool", - "iops = number", - "kms_key_id = string", - "snapshot_id = string", - "throughput = number", - "volume_initialization_rate = optional(number)", - "volume_size = number", - "volume_type = string", - "scale_errors = optional(list(string), [])", + "license_specifications = optional(list(object({", + "license_configuration_arn = string", + "use_dedicated_host = optional(bool, false)", + "log_files = optional(list(object({", + "log_group_name = string", + "prefix_log_group = bool", + "file_path = string", + "log_stream_name = string", + "log_class = optional(string, \"STANDARD\")", + "tags = optional(map(string), {})", "microvm = optional(object({", "image_identifier = string", "image_version = optional(string, null)", @@ -228,6 +267,11 @@ run "platform_forge_runners_interface_contract" { "for provider_type, provider_config in runner_config.compute_provider : provider_type", "if provider_config != null", "error_message = \"Each runner_specs entry must configure exactly one compute provider: ec2 or microvm.\"", + "length(runner_config.compute_provider.ec2.ami[*]) == 1", + "&& try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false)", + "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"", + "length(runner_config.compute_provider.ec2.instance_profile[*]) == 0", + "error_message = \"Forge EC2 runner_specs do not support an external instance_profile.\"", "Compute deployment configuration for GitHub Actions runners.", "- lambda_subnet_ids: Subnets where runner-related lambdas execute.", "These can be more permissive than the runner subnets.", @@ -239,7 +283,7 @@ run "platform_forge_runners_interface_contract" { "- redrive_build_queue: Optional dead-letter queue redrive configuration.", "- compute_provider: Exactly one typed provider block: ec2 or microvm.", "compute_provider.ec2 fields:", - "- scale_errors : Retryable EC2 scale-up error codes.", + "- ami : Upstream-compatible EC2 AMI configuration.", "compute_provider.microvm fields:", "- image_identifier: ARN or ID of the Lambda MicroVM image.", "- iam : Optional MicroVM control-plane IAM overrides.", @@ -336,7 +380,7 @@ run "platform_forge_runners_interface_contract" { condition = ( output.expected_input_variable_count == 10 && output.expected_output_value_count == 5 - && output.expected_interface_literal_count == 277 + && output.expected_interface_literal_count == 321 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl index 3a66c6244..98bc2ab5a 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -22,7 +22,7 @@ run "platform_forge_runners_contract" { "compute_provider = object({", "ec2 = optional(object({", "microvm = optional(object({", - "scale_errors = optional(list(string), [])", + "scale_errors = optional(list(string), [", "for key in local.active_redrive_runner_keys :", "try(module.ec2_runners[0].runners_arn_map, {}),", "runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {})", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index 4e763982c..b0834287b 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -44,16 +44,85 @@ variable "ec2_deployment_specs" { runner_user = string compute_provider = object({ ec2 = optional(object({ - ami_filter = object({ - name = list(string) - state = list(string) - }) - ami_kms_key_arn = string - ami_owners = list(string) - instance_types = list(string) - license_specifications = optional(list(object({ - license_configuration_arn = string - })), null) + metadata_options = optional(object({ + instance_metadata_tags = optional(string, "enabled") + http_endpoint = optional(string, "enabled") + http_tokens = optional(string, "required") + http_put_response_hop_limit = optional(number, 1) + }), {}) + ami = optional(object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter = optional(object({ + arn = string + }), null) + kms_key = optional(object({ + arn = string + }), null) + }), null) + block_device_mappings = optional(list(object({ + delete_on_termination = optional(bool, true) + device_name = optional(string, "/dev/xvda") + encrypted = optional(bool, true) + iops = optional(number) + kms_key_id = optional(string) + snapshot_id = optional(string) + throughput = optional(number) + volume_initialization_rate = optional(number) + volume_size = number + volume_type = optional(string, "gp3") + })), [{ + volume_size = 30 + }]) + create_service_linked_role_spot = optional(bool, false) + credit_specification = optional(string, null) + ebs_optimized = optional(bool, false) + cloudwatch_agent = optional(object({ + enabled = optional(bool, true) + config = optional(string, null) + }), {}) + binaries_syncer = optional(object({ + enabled = optional(bool, true) + }), {}) + detailed_monitoring_enabled = optional(bool, false) + ssm_enabled = optional(bool, false) + user_data = optional(object({ + enabled = optional(bool, true) + template = optional(string, null) + content = optional(string, null) + pre_install = optional(string, "") + post_install = optional(string, "") + debug_logging_enabled = optional(bool, false) + }), {}) + instance_allocation_strategy = optional(string, "lowest-price") + instance_max_spot_price = optional(string, null) + instance_target_capacity_type = optional(string, "spot") + instance_type_priorities = optional(map(number), null) + instance_types = list(string) + additional_security_group_ids = optional(list(string), []) + instance_profile = optional(object({ + name = string + }), null) + enable_on_demand_failover_for_errors = optional(list(string), []) + scale_errors = optional(list(string), [ + "UnfulfillableCapacity", + "MaxSpotInstanceCountExceeded", + "TargetCapacityLimitExceededException", + "RequestLimitExceeded", + "ResourceLimitExceeded", + "MaxSpotInstanceCountExceeded", + "MaxSpotFleetRequestCountExceeded", + "InsufficientInstanceCapacity", + "InsufficientCapacityOnHost", + ]) + subnet_ids = optional(list(string), null) + vpc_id = optional(string, null) + cpu_options = optional(object({ + core_count = optional(number) + threads_per_core = optional(number) + amd_sev_snp = optional(string) + nested_virtualization = optional(string) + }), null) placement = optional(object({ affinity = optional(string) availability_zone = optional(string) @@ -65,24 +134,18 @@ variable "ec2_deployment_specs" { tenancy = optional(string) partition_number = optional(number) }), null) - use_dedicated_host = optional(bool, false) - enable_userdata = bool - instance_target_capacity_type = string - vpc_id = optional(string, null) - subnet_ids = optional(list(string), null) - scale_errors = optional(list(string), []) - block_device_mappings = list(object({ - delete_on_termination = bool - device_name = string - encrypted = bool - iops = number - kms_key_id = string - snapshot_id = string - throughput = number - volume_initialization_rate = optional(number) - volume_size = number - volume_type = string - })) + license_specifications = optional(list(object({ + license_configuration_arn = string + })), []) + use_dedicated_host = optional(bool, false) + log_files = optional(list(object({ + log_group_name = string + prefix_log_group = bool + file_path = string + log_stream_name = string + log_class = optional(string, "STANDARD") + })), null) + tags = optional(map(string), {}) }), null) microvm = optional(object({ image_identifier = string @@ -134,6 +197,25 @@ variable "ec2_deployment_specs" { error_message = "Each runner_specs entry must configure exactly one compute provider: ec2 or microvm." } + validation { + condition = alltrue([ + for runner_config in values(var.ec2_deployment_specs.runner_specs) : + runner_config.compute_provider.ec2 == null ? true : ( + length(runner_config.compute_provider.ec2.ami[*]) == 1 + && try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false) + ) + ]) + error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." + } + + validation { + condition = alltrue([ + for runner_config in values(var.ec2_deployment_specs.runner_specs) : + runner_config.compute_provider.ec2 == null ? true : length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 + ]) + error_message = "Forge EC2 runner_specs do not support an external instance_profile." + } + description = <<-EOT Compute deployment configuration for GitHub Actions runners. @@ -172,18 +254,17 @@ variable "ec2_deployment_specs" { - compute_provider: Exactly one typed provider block: ec2 or microvm. compute_provider.ec2 fields: - - ami_filter : Name/state filters used to select the runner AMI. - - ami_kms_key_arn : KMS key ARN used to encrypt AMI EBS volumes. - - ami_owners : List of AWS account IDs that own the AMI. - - instance_types : Allowed EC2 instance types for runners in this pool. - - placement : Optional EC2 placement configuration. - - license_specifications: Optional EC2 License Manager configuration ARNs. - - use_dedicated_host: Whether this runner pool should use dedicated hosts. - - enable_userdata : Whether to inject the standard runner user data. - - instance_target_capacity_type: EC2 capacity type (spot or on-demand). - - vpc_id/subnet_ids: Optional per-lane network overrides. - - block_device_mappings: EBS volume configuration for runner instances. - - scale_errors : Retryable EC2 scale-up error codes. + - ami : Upstream-compatible EC2 AMI configuration. + Forge requires a module-managed AMI block; null and + external AMI parameter ownership are unsupported. + - metadata_options: EC2 instance metadata service configuration. + - block_device_mappings: EBS mappings for runner instances. + - cloudwatch_agent/binaries_syncer/user_data: Runner bootstrap configuration. + - instance_types and allocation fields: EC2 Fleet capacity configuration. + - vpc_id/subnet_ids/additional_security_group_ids: Per-lane networking. + - cpu_options/placement/license_specifications: EC2 launch-template options. + - instance_profile: Upstream contract field reserved for future Forge support. + - log_files/tags : Provider-specific logging and resource tags. compute_provider.microvm fields: - image_identifier: ARN or ID of the Lambda MicroVM image. From f6d067354ce3d8277461b0070755e0cf8adb5005 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 11 Aug 2026 17:27:44 +0200 Subject: [PATCH 4/5] refactor(runners): keep v2 deployment EC2-only --- docs/configurations/deployments/platform.md | 14 +--- docs/reference/module-catalog.md | 4 +- docs/reference/module-dependencies.md | 2 +- docs/reference/module-layout.md | 12 +-- .../terragrunt/_global_settings/tenant.hcl | 2 +- .../vpcs/main/tenants/acme/config.yml | 1 - .../main/tenants/acme/runner_settings.hcl | 44 ----------- .../terragrunt/environments/terragrunt.hcl | 2 +- .../platform/_global_settings/tenant.hcl | 2 +- examples/templates/platform/tenant/config.yml | 19 ----- .../platform/tenant/runner_settings.hcl | 44 ----------- modules/platform/ec2_deployment/README.md | 39 ++++------ .../ec2_update_runner_ssm_ami.tf | 1 - .../ec2_deployment/ec2_update_runner_tags.tf | 1 - modules/platform/ec2_deployment/main.tf | 10 +-- modules/platform/ec2_deployment/outputs.tf | 42 ----------- modules/platform/ec2_deployment/roles.tf | 6 +- .../ec2_deployment/runner_configs_v2.tf | 25 ++----- .../ec2_deployment/tests/behavior.tftest.hcl | 30 ++------ .../tests/interface_contract.tftest.hcl | 52 ++----------- .../tests/source_inventory.tftest.hcl | 5 -- modules/platform/ec2_deployment/variables.tf | 58 ++------------ modules/platform/forge_runners/README.md | 16 ++-- .../forge_runners/forge_trust_validator.tf | 2 +- modules/platform/forge_runners/outputs.tf | 11 +-- .../tests/interface_contract.tftest.hcl | 51 ++----------- .../tests/source_inventory.tftest.hcl | 7 +- modules/platform/forge_runners/variables.tf | 75 +++---------------- 28 files changed, 83 insertions(+), 494 deletions(-) diff --git a/docs/configurations/deployments/platform.md b/docs/configurations/deployments/platform.md index 4234f2f2b..f4ab04940 100644 --- a/docs/configurations/deployments/platform.md +++ b/docs/configurations/deployments/platform.md @@ -1,8 +1,8 @@ # Platform Deployment This is the main Forge runtime path. It deploys tenant runner control planes -through `modules/platform/forge_runners`, which can create EC2 or Lambda -MicroVM runners and ARC runner scale sets. +through `modules/platform/forge_runners`, which can create EC2 runners and ARC +runner scale sets. Deploy root: @@ -24,7 +24,7 @@ ______________________________________________________________________ | `environments/prod/_environment_wide_settings/_environment.yml` | AWS account, default region, remote state, and naming suffixes. | | `environments/prod/regions/eu-west-1/_region_wide_settings/_region.hcl` | Region and short region alias used in labels and names. | | `environments/prod/regions/eu-west-1/vpcs/main/_vpc_wide_settings/_vpc.yml` | VPC ID, Lambda subnets, runner subnets, VPC alias, and cluster name. | -| `environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml` | Tenant GitHub App, IAM, compute-provider runner specs, and ARC specs. | +| `environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml` | Tenant GitHub App, IAM, EC2 runner specs, and optional ARC specs. | | `release_versions.yml` | Module source, version, and `module_path` metadata. | The example uses `prod`, `eu-west-1`, `main`, and `acme` as copyable defaults. @@ -72,7 +72,7 @@ wide applies. ______________________________________________________________________ -## Compute Providers and ARC +## EC2 Only vs ARC For EC2-only tenants: @@ -81,12 +81,6 @@ For EC2-only tenants: - Skip `examples/deployments/infra`. - Skip Kubernetes and EKS integrations. -For Lambda MicroVM lanes, add entries under `microvm_runner_specs`; the -Terragrunt translation merges them with EC2 lanes into the same provider-aware -`runner_specs` map and prefixes generated lane keys with `microvm-`. Until -upstream publishes the provider-aware Lambda release, draft validation requires -matching PR-built ZIPs through `USE_CACHE` and `CACHE_PATH`. - For ARC tenants: - Deploy or provide an EKS cluster first. diff --git a/docs/reference/module-catalog.md b/docs/reference/module-catalog.md index a7b3c103b..c16a6391f 100644 --- a/docs/reference/module-catalog.md +++ b/docs/reference/module-catalog.md @@ -8,8 +8,8 @@ deployment and what can be skipped. | Module | Role | Required? | Example root | Validation | | ------------------------------------------------------------ | ------------------------------------------------- | ----------------------- | --------------------------------------------------- | ------------------------------------------------- | -| `modules/platform/forge_runners` | Tenant-facing runner platform entrypoint. | Yes | `examples/deployments/platform` | Tenant plan/apply plus compute or ARC smoke job. | -| `modules/platform/ec2_deployment` | EC2 and MicroVM compute-provider runner lanes. | Through `forge_runners` | `examples/deployments/platform` | Selected provider launches and cleans up runners. | +| `modules/platform/forge_runners` | Tenant-facing runner platform entrypoint. | Yes | `examples/deployments/platform` | Tenant plan/apply plus EC2 or ARC smoke job. | +| `modules/platform/ec2_deployment` | EC2 ephemeral runner lane. | Through `forge_runners` | `examples/deployments/platform` | EC2 smoke job launches, registers, and cleans up. | | `modules/platform/arc_deployment` | Tenant ARC runner lane wrapper. | Only for ARC | `examples/deployments/platform` | ARC smoke job and scale-set reconciliation. | | `modules/platform/arc` | ARC controller and scale-set foundation. | Only for ARC | `examples/deployments/infra` and platform ARC specs | `kubectl`, Helm, and ARC resource checks. | | `modules/platform/forge_runners/forge_trust_validator` | Tenant trust validation support. | Through `forge_runners` | `examples/deployments/platform` | Tenant role trust and workflow role assumption. | diff --git a/docs/reference/module-dependencies.md b/docs/reference/module-dependencies.md index 11c0073c6..c0ad49eb3 100644 --- a/docs/reference/module-dependencies.md +++ b/docs/reference/module-dependencies.md @@ -35,7 +35,7 @@ ______________________________________________________________________ | Module | Depends on | Notes | | --------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | | `modules/platform/forge_runners` | Tenant GitHub App values, SSM key parameter, VPC/subnets, runner specs | Main entry point for tenant runners. | -| `modules/platform/ec2_deployment` | Called by `forge_runners`; provider configuration; GitHub App | EC2 or Lambda MicroVM ephemeral runners. | +| `modules/platform/ec2_deployment` | Called by `forge_runners`; runner AMIs; GitHub App | EC2 ephemeral runners. | | `modules/platform/arc_deployment` | Called by `forge_runners`; EKS; Kubernetes/Helm access | ARC scale sets. | | `modules/platform/arc` | EKS cluster and Kubernetes providers | Lower-level ARC controller and scale-set wrapper. | | `modules/infra/eks` | VPC, private subnets, AWS access | Needed only when Forge owns the ARC EKS foundation. | diff --git a/docs/reference/module-layout.md b/docs/reference/module-layout.md index f7cf4258e..941421d21 100644 --- a/docs/reference/module-layout.md +++ b/docs/reference/module-layout.md @@ -25,12 +25,12 @@ ______________________________________________________________________ ## Platform Modules -| Module | Purpose | Directly call it? | -| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| `modules/platform/forge_runners` | Tenant runner entry point. Wires compute-provider and ARC runner specs, GitHub App behavior, trust validation, job logs, relay options, and service catalog data. | Yes. This is the normal tenant-facing module. | -| `modules/platform/ec2_deployment` | EC2 and Lambda MicroVM runner lanes backed by `terraform-aws-github-runner`; the historical path is retained. | Usually through `forge_runners`. | -| `modules/platform/arc_deployment` | Tenant ARC runner lane wrapper. | Usually through `forge_runners`. | -| `modules/platform/arc` | Lower-level ARC controller and scale-set Helm wrapper. | No, unless building a custom platform wrapper. | +| Module | Purpose | Directly call it? | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `modules/platform/forge_runners` | Tenant runner entry point. Wires EC2 and ARC runner specs, GitHub App behavior, trust validation, job logs, relay options, and service catalog data. | Yes. This is the normal tenant-facing module. | +| `modules/platform/ec2_deployment` | EC2 runner lane backed by `terraform-aws-github-runner`. | Usually through `forge_runners`. | +| `modules/platform/arc_deployment` | Tenant ARC runner lane wrapper. | Usually through `forge_runners`. | +| `modules/platform/arc` | Lower-level ARC controller and scale-set Helm wrapper. | No, unless building a custom platform wrapper. | ______________________________________________________________________ diff --git a/examples/deployments/platform/terragrunt/_global_settings/tenant.hcl b/examples/deployments/platform/terragrunt/_global_settings/tenant.hcl index 94dadbc6a..e2a45980b 100644 --- a/examples/deployments/platform/terragrunt/_global_settings/tenant.hcl +++ b/examples/deployments/platform/terragrunt/_global_settings/tenant.hcl @@ -64,7 +64,7 @@ inputs = { lambda_vpc_id = local.config.locals.lambda_vpc_id subnet_ids = local.config.locals.subnet_ids vpc_id = local.config.locals.vpc_id - runner_specs = local.config.locals.runner_specs + runner_specs = local.config.locals.ec2_runner_specs } arc_deployment_specs = { diff --git a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml index b8bac016c..c1d69f6a6 100644 --- a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml +++ b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/config.yml @@ -98,7 +98,6 @@ ec2_runner_specs: iops: 3000 throughput: 125 type: gp3 -microvm_runner_specs: {} arc_runner_specs: dependabot: runner_size: diff --git a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl index 828bb33fe..27b4ed1b4 100644 --- a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl +++ b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl @@ -140,50 +140,6 @@ locals { } } - microvm_runner_specs = { - for name, spec in try(local.config.microvm_runner_specs, {}) : - "microvm-${name}" => { - runner_os = try(spec.runner_os, "linux") - runner_architecture = spec.runner_architecture - runner_labels = [ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ] - extra_labels = [ - "microvm", - "rgn:${local.region_alias}", - "vpc:${local.vpc_alias}", - "tnt:${local.tenant_name}", - ] - enable_dynamic_labels = try(spec.enable_dynamic_labels, false) - aws_dynamic_labels_policy = try(spec.aws_dynamic_labels_policy, null) - lambda_event_source_mapping_batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - lambda_event_source_mapping_maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) - redrive_build_queue = try(spec.redrive_build_queue, {}) - runner_user = spec.runner_user - min_run_time = try(spec.min_run_time, 30) - max_instances = spec.max_instances - pool_config = try(spec.pool_config, []) - compute_provider = { - microvm = { - image_identifier = spec.image_identifier - image_version = try(spec.image_version, null) - egress_network_connectors = try(spec.egress_network_connectors, []) - idle_policy = try(spec.idle_policy, null) - logging = try(spec.logging, null) - run_hook_payload = try(spec.run_hook_payload, null) - maximum_duration_in_seconds = try(spec.maximum_duration_in_seconds, null) - environment_variables = try(spec.environment_variables, {}) - tags = try(spec.tags, {}) - iam = try(spec.iam, {}) - } - } - } - } - - runner_specs = merge(local.ec2_runner_specs, local.microvm_runner_specs) arc_cluster_name = local.config.arc_cluster_name migrate_arc_cluster = local.config.migrate_arc_cluster diff --git a/examples/deployments/platform/terragrunt/environments/terragrunt.hcl b/examples/deployments/platform/terragrunt/environments/terragrunt.hcl index c0443ac42..aa263a735 100644 --- a/examples/deployments/platform/terragrunt/environments/terragrunt.hcl +++ b/examples/deployments/platform/terragrunt/environments/terragrunt.hcl @@ -1,7 +1,7 @@ locals { # Draft dependency on terraform-aws-github-runner PR #5260. Replace this # commit with the first release that includes multi_runner_config_v2 and the - # EC2/MicroVM provider-aware Lambda artifacts. + # EC2 v2 Lambda artifacts. github_runner_module_source = get_env("GITHUB_RUNNER_MODULE_SOURCE", "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=961c1208a3831d19af8c0cfb43a7ed8b2d81e34b") } diff --git a/examples/templates/platform/_global_settings/tenant.hcl b/examples/templates/platform/_global_settings/tenant.hcl index 82279e3ae..eeb4e5df2 100644 --- a/examples/templates/platform/_global_settings/tenant.hcl +++ b/examples/templates/platform/_global_settings/tenant.hcl @@ -59,7 +59,7 @@ inputs = { lambda_vpc_id = local.config.locals.lambda_vpc_id subnet_ids = local.config.locals.subnet_ids vpc_id = local.config.locals.vpc_id - runner_specs = local.config.locals.runner_specs + runner_specs = local.config.locals.ec2_runner_specs } arc_deployment_specs = { diff --git a/examples/templates/platform/tenant/config.yml b/examples/templates/platform/tenant/config.yml index 62bc58154..62b3e8ef7 100644 --- a/examples/templates/platform/tenant/config.yml +++ b/examples/templates/platform/tenant/config.yml @@ -59,25 +59,6 @@ ec2_runner_specs: iops: throughput: type: -microvm_runner_specs: - : - type: - runner_os: linux - runner_architecture: - runner_user: - max_instances: - image_identifier: - image_version: - egress_network_connectors: - - - idle_policy: - max_idle_duration_seconds: - suspended_duration_seconds: - auto_resume_enabled: - maximum_duration_in_seconds: - environment_variables: {} - tags: {} - pool_config: [] arc_runner_specs: : runner_size: diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index 828bb33fe..27b4ed1b4 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -140,50 +140,6 @@ locals { } } - microvm_runner_specs = { - for name, spec in try(local.config.microvm_runner_specs, {}) : - "microvm-${name}" => { - runner_os = try(spec.runner_os, "linux") - runner_architecture = spec.runner_architecture - runner_labels = [ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ] - extra_labels = [ - "microvm", - "rgn:${local.region_alias}", - "vpc:${local.vpc_alias}", - "tnt:${local.tenant_name}", - ] - enable_dynamic_labels = try(spec.enable_dynamic_labels, false) - aws_dynamic_labels_policy = try(spec.aws_dynamic_labels_policy, null) - lambda_event_source_mapping_batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - lambda_event_source_mapping_maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) - redrive_build_queue = try(spec.redrive_build_queue, {}) - runner_user = spec.runner_user - min_run_time = try(spec.min_run_time, 30) - max_instances = spec.max_instances - pool_config = try(spec.pool_config, []) - compute_provider = { - microvm = { - image_identifier = spec.image_identifier - image_version = try(spec.image_version, null) - egress_network_connectors = try(spec.egress_network_connectors, []) - idle_policy = try(spec.idle_policy, null) - logging = try(spec.logging, null) - run_hook_payload = try(spec.run_hook_payload, null) - maximum_duration_in_seconds = try(spec.maximum_duration_in_seconds, null) - environment_variables = try(spec.environment_variables, {}) - tags = try(spec.tags, {}) - iam = try(spec.iam, {}) - } - } - } - } - - runner_specs = merge(local.ec2_runner_specs, local.microvm_runner_specs) arc_cluster_name = local.config.arc_cluster_name migrate_arc_cluster = local.config.migrate_arc_cluster diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index 23cd8ebed..6a8a8b9a7 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -1,30 +1,26 @@ -# Compute Runner Deployment +# EC2 Runner Deployment -This module deploys Forge EC2 and Lambda MicroVM runner pools through the -upstream `terraform-aws-github-runner` multi-runner module. The historical -`ec2_deployment` module path is retained. +This module deploys Forge EC2 runner pools through the upstream +`terraform-aws-github-runner` multi-runner module. ## Why This Module Exists -Provider-aware runner lanes let a tenant choose a full EC2 VM or a Lambda -MicroVM per label set while sharing the same webhook and runner control plane. -Forge keeps EC2 for workloads that need custom AMIs, macOS/Windows, dedicated -hosts, or larger hardware, and can use MicroVMs for Linux workloads supported -by the MicroVM image catalog. +The nested EC2 v2 contract lets each runner lane configure the complete +upstream EC2 provider object while sharing Forge's webhook and runner control +plane. EC2 supports custom AMIs, macOS/Windows, dedicated hosts, and larger +hardware profiles. ## What It Manages - The upstream multi-runner control plane for webhook, scale-up, scale-down, and ephemeral runner registration. -- Per-lane label matching, provider selection, warm pool schedules, and capacity limits. +- Per-lane label matching, warm pool schedules, and capacity limits. - EC2 AMI, instance type, storage, user data, tag, and logging-hook configuration. -- MicroVM image, network connector, idle policy, logging, runtime, and IAM configuration. -- Shared KMS key material and Lambda egress security group, plus EC2-only AMI and tag helpers. +- Shared KMS key material, Lambda egress security group, and EC2 AMI and tag helpers. ## Operational Notes - This is a breaking input migration: every `runner_specs` entry must contain - exactly one non-null `compute_provider.ec2` or `compute_provider.microvm` - block. The legacy flat EC2 shape is not accepted. + `compute_provider.ec2`. The legacy flat EC2 shape is not accepted. - The EC2 block mirrors the upstream v2 nested contract. Forge still owns AMI refresh and runner instance profiles, so EC2 lanes require a non-null, module-managed `ami` block and cannot select `ami.id_ssm_parameter` or an @@ -34,15 +30,13 @@ by the MicroVM image catalog. - The upstream v2 path changes Terraform resource addresses from the v1 runner modules to provider-oriented runner stacks; this module does not include an in-place state migration. -- The upstream dependency is pinned to draft PR #5260. Until its provider-aware +- The upstream dependency is pinned to draft PR #5260. Until its v2 Lambda artifacts are released, plans require matching PR-built ZIPs supplied with `USE_CACHE` and `CACHE_PATH`. -- MicroVM lanes use the upstream module-managed execution role so Forge's - tenant-assumption, ECR, and global-lock policies apply to the runtime identity. - Every compute runner is ephemeral and is expected to register for one job and then be reaped. - Label sets are the API contract with tenant workflows, so exact matching matters. -- Cold starts vary by provider; use warm pools only where latency justifies the idle cost. -- Subnet IP capacity and provider capacity errors are expected operational signals, not unusual exceptions. +- Use warm pools only where startup latency justifies the idle cost. +- Subnet IP capacity and EC2 capacity errors are expected operational signals, not unusual exceptions. ## Requirements @@ -96,7 +90,7 @@ by the MicroVM image catalog. | ---- | ----------- | ---- | ------- | :------: | | [aws\_region](#input\_aws\_region) | Assuming single region for now. | `string` | n/a | yes | | [network\_configs](#input\_network\_configs) | n/a |
object({
vpc_id = string
subnet_ids = list(string)
lambda_vpc_id = string
lambda_subnet_ids = list(string)
})
| n/a | yes | -| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = string
ghes_org = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_group_name = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_identifier = string
image_version = optional(string, null)
egress_network_connectors = optional(list(string), [])
idle_policy = optional(object({
max_idle_duration_seconds = number
suspended_duration_seconds = number
auto_resume_enabled = bool
}), null)
logging = optional(object({
cloud_watch = optional(object({
log_group = optional(string, null)
log_stream = optional(string, null)
}), null)
disabled = optional(bool, false)
}), null)
run_hook_payload = optional(string, null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
tags = optional(map(string), {})
iam = optional(object({
resource_arns = optional(list(string), ["*"])
actions = optional(object({
scale_up = optional(list(string), null)
scale_down = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policy_arns = optional(object({
scale_up = optional(string, null)
pool = optional(string, null)
}), {})
}), {})
}), null)
})
}))
})
| n/a | yes | +| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = string
ghes_org = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_group_name = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
})
})
}))
})
| n/a | yes | | [tenant\_configs](#input\_tenant\_configs) | n/a |
object({
ecr_registries = list(string)
tags = map(string)
})
| n/a | yes | ## Outputs @@ -108,11 +102,6 @@ by the MicroVM image catalog. | [ec2\_runners\_labels\_map](#output\_ec2\_runners\_labels\_map) | Map of EC2 runner keys to their base and extra GitHub labels. | | [ec2\_runners\_map](#output\_ec2\_runners\_map) | Map of EC2 runner keys to their provider-specific resources. | | [event\_bus\_name](#output\_event\_bus\_name) | Name of the EventBridge event bus used by the webhook relay. | -| [microvm\_runners\_arn\_map](#output\_microvm\_runners\_arn\_map) | Map of MicroVM runner keys to their execution role ARNs. | -| [microvm\_runners\_labels\_map](#output\_microvm\_runners\_labels\_map) | Map of MicroVM runner keys to their base and extra GitHub labels. | -| [microvm\_runners\_map](#output\_microvm\_runners\_map) | Map of MicroVM runner keys to their provider-specific resources. | -| [runners\_arn\_map](#output\_runners\_arn\_map) | Map of runner keys to the IAM role ARNs used by their compute runtime. | -| [runners\_labels\_map](#output\_runners\_labels\_map) | Map of runner keys to their base and extra GitHub labels. | | [subnet\_cidr\_blocks](#output\_subnet\_cidr\_blocks) | Map of EC2 runner subnet IDs to their CIDR blocks. | | [webhook\_endpoint](#output\_webhook\_endpoint) | Public HTTPS endpoint URL for the GitHub Actions webhook relay. | diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf index 43e99f51f..1fc915276 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf +++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf @@ -26,7 +26,6 @@ locals { module "ec2_update_runner_ssm_ami" { source = "./ec2_update_runner_ssm_ami" - count = length(local.ec2_runner_configs) > 0 ? 1 : 0 providers = { aws = aws diff --git a/modules/platform/ec2_deployment/ec2_update_runner_tags.tf b/modules/platform/ec2_deployment/ec2_update_runner_tags.tf index 1d7029a63..3c35f4145 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_tags.tf +++ b/modules/platform/ec2_deployment/ec2_update_runner_tags.tf @@ -1,6 +1,5 @@ module "ec2_update_runner_tags" { source = "./ec2_update_runner_tags" - count = length(local.ec2_runner_configs) > 0 ? 1 : 0 providers = { aws = aws diff --git a/modules/platform/ec2_deployment/main.tf b/modules/platform/ec2_deployment/main.tf index 2fca166eb..da284d7d2 100644 --- a/modules/platform/ec2_deployment/main.tf +++ b/modules/platform/ec2_deployment/main.tf @@ -68,9 +68,9 @@ data "aws_subnet" "runner_subnet" { } data "external" "download_lambdas" { - # TODO: Provider-aware Lambda artifacts are not released yet. Draft plans + # TODO: v2 Lambda artifacts are not released yet. Draft plans # must set USE_CACHE/CACHE_PATH to ZIPs built from upstream PR #5260. Once a - # provider-aware release exists, move this ref, the module source, and the + # v2 release exists, move this ref, the module source, and the # terraform-aws-github-runner-ref tag to that release together. program = ["bash", "${path.module}/scripts/download_lambdas.sh", "/tmp/${var.runner_configs.prefix}/", "961c1208a3831d19af8c0cfb43a7ed8b2d81e34b", "github-aws-runners/terraform-aws-github-runner"] } @@ -110,8 +110,6 @@ resource "aws_ssm_parameter" "hook_job_completed" { } data "aws_iam_policy_document" "runner_hooks_ssm_read" { - count = length(local.active_ec2_runner_keys) > 0 ? 1 : 0 - statement { sid = "ReadRunnerHookParameters" effect = "Allow" @@ -124,11 +122,9 @@ data "aws_iam_policy_document" "runner_hooks_ssm_read" { } resource "aws_iam_policy" "runner_hooks_ssm_read" { - count = length(local.active_ec2_runner_keys) > 0 ? 1 : 0 - name = "${var.runner_configs.prefix}-runner-hooks-ssm-read" description = "Allow runners to read their gzip'd job-hook scripts from SSM." - policy = data.aws_iam_policy_document.runner_hooks_ssm_read[0].json + policy = data.aws_iam_policy_document.runner_hooks_ssm_read.json tags = var.tenant_configs.tags } diff --git a/modules/platform/ec2_deployment/outputs.tf b/modules/platform/ec2_deployment/outputs.tf index fec5ff42d..c2290f94a 100644 --- a/modules/platform/ec2_deployment/outputs.tf +++ b/modules/platform/ec2_deployment/outputs.tf @@ -3,25 +3,6 @@ output "webhook_endpoint" { description = "Public HTTPS endpoint URL for the GitHub Actions webhook relay." } -output "runners_arn_map" { - value = merge( - { - for runner_key in keys(local.ec2_runner_configs) : - runner_key => module.runners.runners_map_v2[runner_key].runner.role.arn - }, - { - for runner_key in keys(local.microvm_runner_configs) : - runner_key => module.runners.runners_map_v2[runner_key].provider.microvm.execution_role_arn - }, - ) - description = "Map of runner keys to the IAM role ARNs used by their compute runtime." -} - -output "runners_labels_map" { - value = local.runner_labels - description = "Map of runner keys to their base and extra GitHub labels." -} - output "ec2_runners_map" { value = { for runner_key in keys(local.ec2_runner_configs) : @@ -52,29 +33,6 @@ output "ec2_runners_labels_map" { description = "Map of EC2 runner keys to their base and extra GitHub labels." } -output "microvm_runners_map" { - value = { - for runner_key in keys(local.microvm_runner_configs) : - runner_key => module.runners.runners_map_v2[runner_key].provider.microvm - } - description = "Map of MicroVM runner keys to their provider-specific resources." -} - -output "microvm_runners_arn_map" { - value = { - for runner_key in keys(local.microvm_runner_configs) : - runner_key => module.runners.runners_map_v2[runner_key].provider.microvm.execution_role_arn - } - description = "Map of MicroVM runner keys to their execution role ARNs." -} - -output "microvm_runners_labels_map" { - value = { - for runner_key in keys(local.microvm_runner_configs) : runner_key => local.runner_labels[runner_key] - } - description = "Map of MicroVM runner keys to their base and extra GitHub labels." -} - output "subnet_cidr_blocks" { value = { for id, subnet in data.aws_subnet.runner_subnet : id => subnet.cidr_block } description = "Map of EC2 runner subnet IDs to their CIDR blocks." diff --git a/modules/platform/ec2_deployment/roles.tf b/modules/platform/ec2_deployment/roles.tf index e6e2989ae..ff708b96b 100644 --- a/modules/platform/ec2_deployment/roles.tf +++ b/modules/platform/ec2_deployment/roles.tf @@ -1,6 +1,4 @@ data "aws_iam_policy_document" "ec2_tags" { - count = length(local.active_ec2_runner_keys) > 0 ? 1 : 0 - statement { actions = [ "ec2:CreateTags" @@ -34,11 +32,9 @@ data "aws_iam_policy_document" "ec2_tags" { # Define the actual IAM policy for EC2 tags resource "aws_iam_policy" "ec2_tags" { - count = length(local.active_ec2_runner_keys) > 0 ? 1 : 0 - name = "${var.runner_configs.prefix}-policy-for-ec2-tags" description = "Policy that allows EC2 instances to create tags on themselves." - policy = data.aws_iam_policy_document.ec2_tags[0].json + policy = data.aws_iam_policy_document.ec2_tags.json tags = var.tenant_configs.tags tags_all = var.tenant_configs.tags diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf index ac2faf3b0..f4b27d485 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -1,23 +1,11 @@ locals { - ec2_runner_configs = { - for key, runner_config in var.runner_configs.runner_specs : - key => runner_config - if runner_config.compute_provider.ec2 != null - } - - microvm_runner_configs = { - for key, runner_config in var.runner_configs.runner_specs : - key => runner_config - if runner_config.compute_provider.microvm != null - } + ec2_runner_configs = var.runner_configs.runner_specs active_ec2_runner_oses = { for key, runner_config in local.ec2_runner_configs : key => runner_config.runner_os } - active_ec2_runner_keys = toset(keys(local.ec2_runner_configs)) - active_ec2_subnet_ids = toset(flatten([ for runner_config in values(local.ec2_runner_configs) : runner_config.compute_provider.ec2.subnet_ids == null ? var.network_configs.subnet_ids : runner_config.compute_provider.ec2.subnet_ids @@ -129,7 +117,7 @@ locals { run_as = runner_config.runner_user maximum_count = runner_config.max_instances ephemeral = true - hooks = runner_config.compute_provider.ec2 == null ? {} : { + hooks = { job_started = templatefile( "${local.user_data_prefix}/hook_job_started_${runner_config.runner_os}.tftpl", { @@ -148,9 +136,9 @@ locals { iam = { managed_policy_arns = merge( local.runner_iam_role_managed_policy_arns, - runner_config.compute_provider.ec2 == null ? {} : { - forge_ec2_tags = aws_iam_policy.ec2_tags[0].arn - forge_runner_hooks_ssm = aws_iam_policy.runner_hooks_ssm_read[0].arn + { + forge_ec2_tags = aws_iam_policy.ec2_tags.arn + forge_runner_hooks_ssm = aws_iam_policy.runner_hooks_ssm_read.arn }, ) } @@ -185,8 +173,7 @@ locals { } compute_provider = { - ec2 = runner_config.compute_provider.ec2 == null ? null : local.ec2_compute_provider[key] - microvm = runner_config.compute_provider.microvm + ec2 = local.ec2_compute_provider[key] } matcherConfig = { diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index e747c191e..608f51143 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -167,26 +167,11 @@ variables { } } - microvm = { - runner_labels = ["self-hosted", "microvm"] - runner_os = "linux" - runner_architecture = "x64" - extra_labels = [] - max_instances = 2 - min_run_time = 5 - pool_config = [] - runner_user = "runner" - compute_provider = { - microvm = { - image_identifier = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:test" - } - } - } } } } -run "mixed_provider_plan" { +run "ec2_v2_plan" { command = plan plan_options { @@ -202,8 +187,8 @@ run "mixed_provider_plan" { } assert { - condition = toset(keys(local.multi_runner_config_v2)) == toset(["ec2", "microvm"]) - error_message = "The upstream v2 map must preserve all provider lane keys." + condition = toset(keys(local.multi_runner_config_v2)) == toset(["ec2"]) + error_message = "The upstream v2 map must preserve every EC2 lane key." } assert { @@ -217,11 +202,8 @@ run "mixed_provider_plan" { && tolist(local.multi_runner_config_v2.ec2.compute_provider.ec2.ami.filter.name) == tolist(["forge-*"]) && local.multi_runner_config_v2.ec2.compute_provider.ec2.ami.id_ssm_parameter == null && local.multi_runner_config_v2.ec2.compute_provider.ec2.ebs_optimized - && local.multi_runner_config_v2.ec2.compute_provider.microvm == null - && local.multi_runner_config_v2.microvm.compute_provider.ec2 == null - && local.multi_runner_config_v2.microvm.compute_provider.microvm.image_identifier == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:test" ) - error_message = "The v2 translation must select exactly one configured provider per lane." + error_message = "The v2 translation must preserve the nested EC2 provider configuration." } assert { @@ -235,8 +217,8 @@ run "mixed_provider_plan" { assert { condition = ( - length(local.multi_runner_config_v2.microvm.matcherConfig.labelMatchers) == 1 - && tolist(local.multi_runner_config_v2.microvm.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "microvm"]) + length(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers) == 1 + && tolist(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) ) error_message = "Empty extra labels must retain the base label matcher." } diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index 2b4838c35..82cc7fd6b 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -19,11 +19,6 @@ run "platform_ec2_deployment_interface_contract" { "ec2_runners_labels_map", "ec2_runners_map", "event_bus_name", - "microvm_runners_arn_map", - "microvm_runners_labels_map", - "microvm_runners_map", - "runners_arn_map", - "runners_labels_map", "subnet_cidr_blocks", "webhook_endpoint", ] @@ -75,7 +70,7 @@ run "platform_ec2_deployment_interface_contract" { "schedule_expression_timezone = string", "runner_user = string", "compute_provider = object({", - "ec2 = optional(object({", + "ec2 = object({", "metadata_options = optional(object({", "instance_metadata_tags = optional(string, \"enabled\")", "http_endpoint = optional(string, \"enabled\")", @@ -150,38 +145,11 @@ run "platform_ec2_deployment_interface_contract" { "log_stream_name = string", "log_class = optional(string, \"STANDARD\")", "tags = optional(map(string), {})", - "microvm = optional(object({", - "image_identifier = string", - "image_version = optional(string, null)", - "egress_network_connectors = optional(list(string), [])", - "idle_policy = optional(object({", - "max_idle_duration_seconds = number", - "suspended_duration_seconds = number", - "auto_resume_enabled = bool", - "logging = optional(object({", - "cloud_watch = optional(object({", - "log_group = optional(string, null)", - "log_stream = optional(string, null)", - "disabled = optional(bool, false)", - "run_hook_payload = optional(string, null)", - "maximum_duration_in_seconds = optional(number, null)", - "environment_variables = optional(map(string), {})", - "tags = optional(map(string), {})", - "iam = optional(object({", - "resource_arns = optional(list(string), [\"*\"])", - "scale_up = optional(list(string), null)", - "scale_down = optional(list(string), null)", - "additional_policy_json = optional(object({", - "managed_policy_arns = optional(object({", - "pool = optional(string, null)", "for runner_config in values(var.runner_configs.runner_specs) :", - "if provider_config != null", - "error_message = \"Each runner_specs entry must configure exactly one compute provider: ec2 or microvm.\"", "variable \"tenant_configs\"", "ecr_registries = list(string)", "tags = map(string)", - "if runner_config.compute_provider.ec2 != null", - "if runner_config.compute_provider.microvm != null", + "ec2_runner_configs = var.runner_configs.runner_specs", "length(runner_config.compute_provider.ec2.ami[*]) == 1", "&& try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false)", "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"", @@ -205,15 +173,11 @@ run "platform_ec2_deployment_interface_contract" { "tags = merge(var.tenant_configs.tags, runner_config.compute_provider.ec2.tags)", "managed_policy_arns = merge(", "local.runner_iam_role_managed_policy_arns,", - "forge_ec2_tags = aws_iam_policy.ec2_tags[0].arn", - "forge_runner_hooks_ssm = aws_iam_policy.runner_hooks_ssm_read[0].arn", + "forge_ec2_tags = aws_iam_policy.ec2_tags.arn", + "forge_runner_hooks_ssm = aws_iam_policy.runner_hooks_ssm_read.arn", "labelMatchers = length(runner_config.extra_labels) == 0 ? [runner_config.runner_labels] : concat(", "multi_runner_config = {}", "multi_runner_config_v2 = local.multi_runner_config_v2", - "output \"runners_arn_map\"", - "runner_key => module.runners.runners_map_v2[runner_key].provider.microvm.execution_role_arn", - "output \"runners_labels_map\"", - "value = local.runner_labels", "output \"ec2_runners_map\"", "module.runners.runners_map_v2[runner_key].provider.ec2", "output \"ec2_runners_arn_map\"", @@ -221,10 +185,6 @@ run "platform_ec2_deployment_interface_contract" { "output \"ec2_runners_ami_name_map\"", "data.aws_ami.runner_ami[runner_key].name", "output \"ec2_runners_labels_map\"", - "output \"microvm_runners_map\"", - "module.runners.runners_map_v2[runner_key].provider.microvm", - "output \"microvm_runners_arn_map\"", - "output \"microvm_runners_labels_map\"", "output \"event_bus_name\"", "value = module.runners.webhook.eventbridge.event_bus.name", "output \"subnet_cidr_blocks\"", @@ -262,8 +222,8 @@ run "platform_ec2_deployment_interface_contract" { assert { condition = ( output.expected_input_variable_count == 4 - && output.expected_output_value_count == 12 - && output.expected_interface_literal_count == 203 + && output.expected_output_value_count == 7 + && output.expected_interface_literal_count == 168 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl index 163b90249..f96ce3fb9 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -28,15 +28,10 @@ run "platform_ec2_deployment_contract" { "data \"aws_iam_policy_document\" \"runner_hooks_ssm_read\"", "data \"aws_iam_policy_document\" \"ec2_tags\"", "output \"webhook_endpoint\"", - "output \"runners_arn_map\"", - "output \"runners_labels_map\"", "output \"ec2_runners_map\"", "output \"ec2_runners_arn_map\"", "output \"ec2_runners_ami_name_map\"", "output \"ec2_runners_labels_map\"", - "output \"microvm_runners_map\"", - "output \"microvm_runners_arn_map\"", - "output \"microvm_runners_labels_map\"", "output \"subnet_cidr_blocks\"", "output \"event_bus_name\"", ] diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index 3a4cddf5f..121c58033 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -47,7 +47,7 @@ variable "runner_configs" { })) runner_user = string compute_provider = object({ - ec2 = optional(object({ + ec2 = object({ metadata_options = optional(object({ instance_metadata_tags = optional(string, "enabled") http_endpoint = optional(string, "enabled") @@ -150,42 +150,7 @@ variable "runner_configs" { log_class = optional(string, "STANDARD") })), null) tags = optional(map(string), {}) - }), null) - microvm = optional(object({ - image_identifier = string - image_version = optional(string, null) - egress_network_connectors = optional(list(string), []) - idle_policy = optional(object({ - max_idle_duration_seconds = number - suspended_duration_seconds = number - auto_resume_enabled = bool - }), null) - logging = optional(object({ - cloud_watch = optional(object({ - log_group = optional(string, null) - log_stream = optional(string, null) - }), null) - disabled = optional(bool, false) - }), null) - run_hook_payload = optional(string, null) - maximum_duration_in_seconds = optional(number, null) - environment_variables = optional(map(string), {}) - tags = optional(map(string), {}) - iam = optional(object({ - resource_arns = optional(list(string), ["*"]) - actions = optional(object({ - scale_up = optional(list(string), null) - scale_down = optional(list(string), null) - }), {}) - additional_policy_json = optional(object({ - scale_up = optional(string, null) - }), {}) - managed_policy_arns = optional(object({ - scale_up = optional(string, null) - pool = optional(string, null) - }), {}) - }), {}) - }), null) + }) }) })) }) @@ -193,21 +158,8 @@ variable "runner_configs" { validation { condition = alltrue([ for runner_config in values(var.runner_configs.runner_specs) : - length([ - for provider_type, provider_config in runner_config.compute_provider : provider_type - if provider_config != null - ]) == 1 - ]) - error_message = "Each runner_specs entry must configure exactly one compute provider: ec2 or microvm." - } - - validation { - condition = alltrue([ - for runner_config in values(var.runner_configs.runner_specs) : - runner_config.compute_provider.ec2 == null ? true : ( - length(runner_config.compute_provider.ec2.ami[*]) == 1 - && try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false) - ) + length(runner_config.compute_provider.ec2.ami[*]) == 1 + && try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false) ]) error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." } @@ -215,7 +167,7 @@ variable "runner_configs" { validation { condition = alltrue([ for runner_config in values(var.runner_configs.runner_specs) : - runner_config.compute_provider.ec2 == null ? true : length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 + length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 ]) error_message = "Forge EC2 runner_specs do not support an external instance_profile." } diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index d8bad2e9b..8b52ecfcf 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -4,11 +4,11 @@ This is the umbrella tenant module for Forge GitHub Actions runners. ## Why This Module Exists -Forge is a multi-tenant CI platform built around ephemeral runners, short-lived identity, and centralized operations. This module composes the tenant stack: EC2 and Lambda MicroVM compute runners, ARC runners, GitHub App settings, runner group reconciliation, trust validation, log archival, webhook relay, and self-healing support utilities. +Forge is a multi-tenant CI platform built around ephemeral runners, short-lived identity, and centralized operations. This module composes the tenant stack: EC2 runners, ARC runners, GitHub App settings, runner group reconciliation, trust validation, log archival, webhook relay, and self-healing support utilities. ## What It Manages -- Provider-aware EC2/MicroVM compute deployment and ARC runner deployment. +- EC2 and ARC runner deployment. - GitHub App secret material in SSM Parameter Store. - IAM policies that let runners assume tenant-approved roles and pull allowed ECR images. - Runner group registration, trust validation, global lock, job log archival, webhook relay, and DLQ redrive helpers. @@ -18,11 +18,9 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived - This is the best starting point when onboarding or debugging a tenant because it shows how the platform pieces compose. - The tenant `deployment_config` defines the GitHub App, runner group, repository selection, and IAM bridge. -- EC2, MicroVM, and ARC lanes can be enabled for the same tenant; workflows choose by labels. -- `ec2_deployment_specs.runner_specs` now uses a breaking provider-aware shape; - every entry must select exactly one EC2 or MicroVM provider. -- MicroVM execution roles are module-managed so Forge workload policies stay - attached to the identity used by runner jobs. +- EC2 and ARC lanes can be enabled for the same tenant; workflows choose by labels. +- `ec2_deployment_specs.runner_specs` now uses a breaking nested EC2 v2 shape; + every entry must configure `compute_provider.ec2`. - Changing GitHub App or runner-group settings can affect job routing immediately. ## Deployment version inventory @@ -118,7 +116,7 @@ tag with the expected ref in `release_versions.yml`. | [aws\_region](#input\_aws\_region) | AWS region where Forge runners and supporting infrastructure are deployed. | `string` | n/a | yes | | [default\_tags](#input\_default\_tags) | A map of tags to apply to resources. | `map(string)` | n/a | yes | | [deployment\_config](#input\_deployment\_config) | High-level deployment configuration for a Forge runner installation.

Top-level fields:
- deployment\_prefix: Prefix used when naming resources (for example,
log groups, KMS keys, and SSM parameters).
- env : Logical environment name (for example, dev, stage,
prod). Used for tagging and dashboards.

github\_app object:
- id : Numeric GitHub App ID.
- client\_id : OAuth client ID for the app.
- installation\_id: GitHub App installation ID for this tenant.
- name : GitHub App name, used to build URLs and logs.

github object:
- ghes\_org : GitHub organization that owns the repos where
runners will be used.
- ghes\_url : GitHub.com or GHES base URL. Empty string implies
public github.com.
- repository\_selection: Scope for runners (all or selected repositories).
- runner\_group\_name : GitHub runner group to attach new runners to.

tenant object:
- name : Tenant identifier used in naming and
tagging.
- iam\_roles\_to\_assume : Optional list of IAM role ARNs that
runners are allowed to assume for workload execution.
- ecr\_registries : Optional list of ECR registry URLs that
runners may need to pull images from.
- github\_logs\_reader\_role\_arns: Optional list of IAM roles that can read
GitHub Actions logs for this tenant. |
object({
deployment_prefix = string
secret_suffix = string
env = string
github_app = object({
id = string
client_id = string
installation_id = string
name = string
})
github = object({
ghes_org = string
ghes_url = string
repository_selection = string
runner_group_name = string
})
tenant = object({
name = string
iam_roles_to_assume = optional(list(string), [])
ecr_registries = optional(list(string), [])
github_logs_reader_role_arns = optional(list(string), [])
})
})
| n/a | yes | -| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | Compute deployment configuration for GitHub Actions runners.

Top-level fields:
- lambda\_subnet\_ids: Subnets where runner-related lambdas execute.
These can be more permissive than the runner subnets.
- subnet\_ids : Default subnets for compute providers that use the VPC.
- vpc\_id : VPC that contains both runner and lambda subnets.
- runner\_specs : Map of provider-aware runner lanes.

runner\_specs[*] object fields:
- runner\_labels : Base GitHub labels applied to jobs for this pool.
- runner\_os : Runner operating system (for example, linux).
- runner\_architecture: CPU architecture (for example, x86\_64 or arm64).
- extra\_labels : Additional GitHub labels that further specialize
this runner pool.
- enable\_dynamic\_labels: Enables dynamic `ghr-` labels for this runner
pool.
- aws\_dynamic\_labels\_policy: Optional policy for `ghr-ec2-*` labels for
this runner pool.
- lambda\_event\_source\_mapping\_batch\_size: Optional maximum number of queued
jobs passed to the scale-up Lambda per invocation.
- lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: Optional
maximum time to collect queued jobs before invoking the
scale-up Lambda.
- redrive\_build\_queue: Optional dead-letter queue redrive configuration.
Controls whether redrive is enabled and how many times a
message can be received before moving to the dead-letter
queue.
- max\_instances : Maximum number of runners in this pool.
- min\_run\_time : Minimum job run time (in minutes) before a runner
is eligible for scale-down.
- pool\_config : List of pool size schedules (size + cron expression
and optional time zone) controlling baseline capacity.
- runner\_user : OS user under which the GitHub runner process runs.
- compute\_provider: Exactly one typed provider block: ec2 or microvm.

compute\_provider.ec2 fields:
- ami : Upstream-compatible EC2 AMI configuration.
Forge requires a module-managed AMI block; null and
external AMI parameter ownership are unsupported.
- metadata\_options: EC2 instance metadata service configuration.
- block\_device\_mappings: EBS mappings for runner instances.
- cloudwatch\_agent/binaries\_syncer/user\_data: Runner bootstrap configuration.
- instance\_types and allocation fields: EC2 Fleet capacity configuration.
- vpc\_id/subnet\_ids/additional\_security\_group\_ids: Per-lane networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Upstream contract field reserved for future Forge support.
- log\_files/tags : Provider-specific logging and resource tags.

compute\_provider.microvm fields:
- image\_identifier: ARN or ID of the Lambda MicroVM image.
- image\_version : Optional Lambda MicroVM image version.
- egress\_network\_connectors: Optional Lambda MicroVM network connectors.
- idle\_policy/logging/run\_hook\_payload: Optional runtime behavior.
- maximum\_duration\_in\_seconds: Optional maximum MicroVM lifetime.
- environment\_variables/tags: Provider-specific runtime configuration.
- iam : Optional MicroVM control-plane IAM overrides. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_identifier = string
image_version = optional(string, null)
egress_network_connectors = optional(list(string), [])
idle_policy = optional(object({
max_idle_duration_seconds = number
suspended_duration_seconds = number
auto_resume_enabled = bool
}), null)
logging = optional(object({
cloud_watch = optional(object({
log_group = optional(string, null)
log_stream = optional(string, null)
}), null)
disabled = optional(bool, false)
}), null)
run_hook_payload = optional(string, null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
tags = optional(map(string), {})
iam = optional(object({
resource_arns = optional(list(string), ["*"])
actions = optional(object({
scale_up = optional(list(string), null)
scale_down = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policy_arns = optional(object({
scale_up = optional(string, null)
pool = optional(string, null)
}), {})
}), {})
}), null)
})
}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | Compute deployment configuration for GitHub Actions runners.

Top-level fields:
- lambda\_subnet\_ids: Subnets where runner-related lambdas execute.
These can be more permissive than the runner subnets.
- subnet\_ids : Default subnets for EC2 runners.
- vpc\_id : VPC that contains both runner and lambda subnets.
- runner\_specs : Map of EC2 runner lanes.

runner\_specs[*] object fields:
- runner\_labels : Base GitHub labels applied to jobs for this pool.
- runner\_os : Runner operating system (for example, linux).
- runner\_architecture: CPU architecture (for example, x86\_64 or arm64).
- extra\_labels : Additional GitHub labels that further specialize
this runner pool.
- enable\_dynamic\_labels: Enables dynamic `ghr-` labels for this runner
pool.
- aws\_dynamic\_labels\_policy: Optional policy for `ghr-ec2-*` labels for
this runner pool.
- lambda\_event\_source\_mapping\_batch\_size: Optional maximum number of queued
jobs passed to the scale-up Lambda per invocation.
- lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: Optional
maximum time to collect queued jobs before invoking the
scale-up Lambda.
- redrive\_build\_queue: Optional dead-letter queue redrive configuration.
Controls whether redrive is enabled and how many times a
message can be received before moving to the dead-letter
queue.
- max\_instances : Maximum number of runners in this pool.
- min\_run\_time : Minimum job run time (in minutes) before a runner
is eligible for scale-down.
- pool\_config : List of pool size schedules (size + cron expression
and optional time zone) controlling baseline capacity.
- runner\_user : OS user under which the GitHub runner process runs.
- compute\_provider: Nested upstream EC2 provider configuration.

compute\_provider.ec2 fields:
- ami : Upstream-compatible EC2 AMI configuration.
Forge requires a module-managed AMI block; null and
external AMI parameter ownership are unsupported.
- metadata\_options: EC2 instance metadata service configuration.
- block\_device\_mappings: EBS mappings for runner instances.
- cloudwatch\_agent/binaries\_syncer/user\_data: Runner bootstrap configuration.
- instance\_types and allocation fields: EC2 Fleet capacity configuration.
- vpc\_id/subnet\_ids/additional\_security\_group\_ids: Per-lane networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Upstream contract field reserved for future Forge support.
- log\_files/tags : EC2 logging and resource tags. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
})
})
}))
})
| n/a | yes | | [github\_webhook\_relay](#input\_github\_webhook\_relay) | Configuration for the (optional) webhook relay source module.
If enabled=true we provision the API Gateway + source EventBridge forwarding rule.
destination\_event\_bus\_name must already exist or be created in the destination account (or via the destination submodule run there). |
object({
enabled = bool
destination_account_id = optional(string)
destination_event_bus_name = optional(string)
destination_region = optional(string)
destination_reader_role_arn = optional(string)
})
|
{
"destination_account_id": "",
"destination_event_bus_name": "",
"destination_reader_role_arn": "",
"destination_region": "",
"enabled": false
}
| no | | [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | n/a | yes | | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Logging retention period in days. | `string` | n/a | yes | @@ -131,6 +129,6 @@ tag with the expected ref in `release_versions.yml`. | [forge\_core](#output\_forge\_core) | Core tenant-level metadata (non-sensitive). | | [forge\_github\_actions\_job\_logs](#output\_forge\_github\_actions\_job\_logs) | GitHub Actions job log archival resources. | | [forge\_github\_app](#output\_forge\_github\_app) | GitHub App related outputs. | -| [forge\_runners](#output\_forge\_runners) | Combined compute-provider and ARC runner outputs. | +| [forge\_runners](#output\_forge\_runners) | Combined runners output (EC2 + ARC) | | [forge\_webhook\_relay](#output\_forge\_webhook\_relay) | Webhook relay integration outputs. | diff --git a/modules/platform/forge_runners/forge_trust_validator.tf b/modules/platform/forge_runners/forge_trust_validator.tf index 99314c55d..beb77721c 100644 --- a/modules/platform/forge_runners/forge_trust_validator.tf +++ b/modules/platform/forge_runners/forge_trust_validator.tf @@ -13,7 +13,7 @@ module "forge_trust_validator" { forge_iam_roles = { for idx, arn in values(merge( - try(module.ec2_runners[0].runners_arn_map, {}), + try(module.ec2_runners[0].ec2_runners_arn_map, {}), try(module.arc_runners.arc_runners_arn_map, {}), )) : idx => arn diff --git a/modules/platform/forge_runners/outputs.tf b/modules/platform/forge_runners/outputs.tf index 951c82499..d03f7b97e 100644 --- a/modules/platform/forge_runners/outputs.tf +++ b/modules/platform/forge_runners/outputs.tf @@ -7,23 +7,14 @@ output "forge_core" { } output "forge_runners" { - description = "Combined compute-provider and ARC runner outputs." + description = "Combined runners output (EC2 + ARC)" value = { - compute = { - runners_arn_map = try(module.ec2_runners[0].runners_arn_map, {}) - runner_labels = try(module.ec2_runners[0].runners_labels_map, {}) - } ec2 = { runners_arn_map = try(module.ec2_runners[0].ec2_runners_arn_map, {}) ami_name_map = try(module.ec2_runners[0].ec2_runners_ami_name_map, {}) subnet_cidr_blocks = try(module.ec2_runners[0].subnet_cidr_blocks, []) runner_labels = try(module.ec2_runners[0].ec2_runners_labels_map, {}) } - microvm = { - runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {}) - runners = try(module.ec2_runners[0].microvm_runners_map, {}) - runner_labels = try(module.ec2_runners[0].microvm_runners_labels_map, {}) - } arc = { cluster_name = try(module.arc_runners.arc_cluster_name, {}) runners_arn_map = try(module.arc_runners.arc_runners_arn_map, {}) diff --git a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl index e2639d67b..168362968 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -162,7 +162,7 @@ run "platform_forge_runners_interface_contract" { "schedule_expression_timezone = string", "runner_user = string", "compute_provider = object({", - "ec2 = optional(object({", + "ec2 = object({", "metadata_options = optional(object({", "instance_metadata_tags = optional(string, \"enabled\")", "http_endpoint = optional(string, \"enabled\")", @@ -237,36 +237,7 @@ run "platform_forge_runners_interface_contract" { "log_stream_name = string", "log_class = optional(string, \"STANDARD\")", "tags = optional(map(string), {})", - "microvm = optional(object({", - "image_identifier = string", - "image_version = optional(string, null)", - "egress_network_connectors = optional(list(string), [])", - "idle_policy = optional(object({", - "max_idle_duration_seconds = number", - "suspended_duration_seconds = number", - "auto_resume_enabled = bool", - "logging = optional(object({", - "cloud_watch = optional(object({", - "log_group = optional(string, null)", - "log_stream = optional(string, null)", - "disabled = optional(bool, false)", - "run_hook_payload = optional(string, null)", - "maximum_duration_in_seconds = optional(number, null)", - "environment_variables = optional(map(string), {})", - "tags = optional(map(string), {})", - "iam = optional(object({", - "resource_arns = optional(list(string), [\"*\"])", - "actions = optional(object({", - "scale_up = optional(list(string), null)", - "scale_down = optional(list(string), null)", - "additional_policy_json = optional(object({", - "scale_up = optional(string, null)", - "managed_policy_arns = optional(object({", - "pool = optional(string, null)", "for runner_config in values(var.ec2_deployment_specs.runner_specs) :", - "for provider_type, provider_config in runner_config.compute_provider : provider_type", - "if provider_config != null", - "error_message = \"Each runner_specs entry must configure exactly one compute provider: ec2 or microvm.\"", "length(runner_config.compute_provider.ec2.ami[*]) == 1", "&& try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false)", "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"", @@ -275,18 +246,15 @@ run "platform_forge_runners_interface_contract" { "Compute deployment configuration for GitHub Actions runners.", "- lambda_subnet_ids: Subnets where runner-related lambdas execute.", "These can be more permissive than the runner subnets.", - "- subnet_ids : Default subnets for compute providers that use the VPC.", + "- subnet_ids : Default subnets for EC2 runners.", "- vpc_id : VPC that contains both runner and lambda subnets.", - "- runner_specs : Map of provider-aware runner lanes.", + "- runner_specs : Map of EC2 runner lanes.", "- runner_labels : Base GitHub labels applied to jobs for this pool.", "- runner_os : Runner operating system (for example, linux).", "- redrive_build_queue: Optional dead-letter queue redrive configuration.", - "- compute_provider: Exactly one typed provider block: ec2 or microvm.", + "- compute_provider: Nested upstream EC2 provider configuration.", "compute_provider.ec2 fields:", "- ami : Upstream-compatible EC2 AMI configuration.", - "compute_provider.microvm fields:", - "- image_identifier: ARN or ID of the Lambda MicroVM image.", - "- iam : Optional MicroVM control-plane IAM overrides.", "variable \"github_webhook_relay\"", "Configuration for the (optional) webhook relay source module.", "If enabled=true we provision the API Gateway + source EventBridge forwarding rule.", @@ -325,19 +293,12 @@ run "platform_forge_runners_interface_contract" { "name = var.deployment_config.github_app.name", "sensitive = true", "output \"forge_runners\"", - "description = \"Combined compute-provider and ARC runner outputs.\"", - "compute = {", - "runners_arn_map = try(module.ec2_runners[0].runners_arn_map, {})", - "runner_labels = try(module.ec2_runners[0].runners_labels_map, {})", + "description = \"Combined runners output (EC2 + ARC)\"", "ec2 = {", "runners_arn_map = try(module.ec2_runners[0].ec2_runners_arn_map, {})", "ami_name_map = try(module.ec2_runners[0].ec2_runners_ami_name_map, {})", "subnet_cidr_blocks = try(module.ec2_runners[0].subnet_cidr_blocks, [])", "runner_labels = try(module.ec2_runners[0].ec2_runners_labels_map, {})", - "microvm = {", - "runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {})", - "runners = try(module.ec2_runners[0].microvm_runners_map, {})", - "runner_labels = try(module.ec2_runners[0].microvm_runners_labels_map, {})", "arc = {", "cluster_name = try(module.arc_runners.arc_cluster_name, {})", "runners_arn_map = try(module.arc_runners.arc_runners_arn_map, {})", @@ -380,7 +341,7 @@ run "platform_forge_runners_interface_contract" { condition = ( output.expected_input_variable_count == 10 && output.expected_output_value_count == 5 - && output.expected_interface_literal_count == 321 + && output.expected_interface_literal_count == 282 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl index 98bc2ab5a..5453a9a27 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -20,13 +20,10 @@ run "platform_forge_runners_contract" { "active_runner_keys = toset(keys(var.ec2_deployment_specs.runner_specs))", "runner_specs = var.ec2_deployment_specs.runner_specs", "compute_provider = object({", - "ec2 = optional(object({", - "microvm = optional(object({", + "ec2 = object({", "scale_errors = optional(list(string), [", "for key in local.active_redrive_runner_keys :", - "try(module.ec2_runners[0].runners_arn_map, {}),", - "runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {})", - "runners = try(module.ec2_runners[0].microvm_runners_map, {})", + "try(module.ec2_runners[0].ec2_runners_arn_map, {}),", "resource \"aws_iam_policy\" \"role_assumption_for_forge_runners\"", "resource \"aws_iam_policy\" \"ecr_access_for_ec2_instances\"", "resource \"aws_servicecatalogappregistry_application\" \"forge\"", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index b0834287b..883f80cb9 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -43,7 +43,7 @@ variable "ec2_deployment_specs" { })) runner_user = string compute_provider = object({ - ec2 = optional(object({ + ec2 = object({ metadata_options = optional(object({ instance_metadata_tags = optional(string, "enabled") http_endpoint = optional(string, "enabled") @@ -146,42 +146,7 @@ variable "ec2_deployment_specs" { log_class = optional(string, "STANDARD") })), null) tags = optional(map(string), {}) - }), null) - microvm = optional(object({ - image_identifier = string - image_version = optional(string, null) - egress_network_connectors = optional(list(string), []) - idle_policy = optional(object({ - max_idle_duration_seconds = number - suspended_duration_seconds = number - auto_resume_enabled = bool - }), null) - logging = optional(object({ - cloud_watch = optional(object({ - log_group = optional(string, null) - log_stream = optional(string, null) - }), null) - disabled = optional(bool, false) - }), null) - run_hook_payload = optional(string, null) - maximum_duration_in_seconds = optional(number, null) - environment_variables = optional(map(string), {}) - tags = optional(map(string), {}) - iam = optional(object({ - resource_arns = optional(list(string), ["*"]) - actions = optional(object({ - scale_up = optional(list(string), null) - scale_down = optional(list(string), null) - }), {}) - additional_policy_json = optional(object({ - scale_up = optional(string, null) - }), {}) - managed_policy_arns = optional(object({ - scale_up = optional(string, null) - pool = optional(string, null) - }), {}) - }), {}) - }), null) + }) }) })) }) @@ -189,21 +154,8 @@ variable "ec2_deployment_specs" { validation { condition = alltrue([ for runner_config in values(var.ec2_deployment_specs.runner_specs) : - length([ - for provider_type, provider_config in runner_config.compute_provider : provider_type - if provider_config != null - ]) == 1 - ]) - error_message = "Each runner_specs entry must configure exactly one compute provider: ec2 or microvm." - } - - validation { - condition = alltrue([ - for runner_config in values(var.ec2_deployment_specs.runner_specs) : - runner_config.compute_provider.ec2 == null ? true : ( - length(runner_config.compute_provider.ec2.ami[*]) == 1 - && try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false) - ) + length(runner_config.compute_provider.ec2.ami[*]) == 1 + && try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false) ]) error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." } @@ -211,7 +163,7 @@ variable "ec2_deployment_specs" { validation { condition = alltrue([ for runner_config in values(var.ec2_deployment_specs.runner_specs) : - runner_config.compute_provider.ec2 == null ? true : length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 + length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 ]) error_message = "Forge EC2 runner_specs do not support an external instance_profile." } @@ -222,9 +174,9 @@ variable "ec2_deployment_specs" { Top-level fields: - lambda_subnet_ids: Subnets where runner-related lambdas execute. These can be more permissive than the runner subnets. - - subnet_ids : Default subnets for compute providers that use the VPC. + - subnet_ids : Default subnets for EC2 runners. - vpc_id : VPC that contains both runner and lambda subnets. - - runner_specs : Map of provider-aware runner lanes. + - runner_specs : Map of EC2 runner lanes. runner_specs[*] object fields: - runner_labels : Base GitHub labels applied to jobs for this pool. @@ -251,7 +203,7 @@ variable "ec2_deployment_specs" { - pool_config : List of pool size schedules (size + cron expression and optional time zone) controlling baseline capacity. - runner_user : OS user under which the GitHub runner process runs. - - compute_provider: Exactly one typed provider block: ec2 or microvm. + - compute_provider: Nested upstream EC2 provider configuration. compute_provider.ec2 fields: - ami : Upstream-compatible EC2 AMI configuration. @@ -264,16 +216,7 @@ variable "ec2_deployment_specs" { - vpc_id/subnet_ids/additional_security_group_ids: Per-lane networking. - cpu_options/placement/license_specifications: EC2 launch-template options. - instance_profile: Upstream contract field reserved for future Forge support. - - log_files/tags : Provider-specific logging and resource tags. - - compute_provider.microvm fields: - - image_identifier: ARN or ID of the Lambda MicroVM image. - - image_version : Optional Lambda MicroVM image version. - - egress_network_connectors: Optional Lambda MicroVM network connectors. - - idle_policy/logging/run_hook_payload: Optional runtime behavior. - - maximum_duration_in_seconds: Optional maximum MicroVM lifetime. - - environment_variables/tags: Provider-specific runtime configuration. - - iam : Optional MicroVM control-plane IAM overrides. + - log_files/tags : EC2 logging and resource tags. EOT } From f075f5bd8bc6f6322433ee411e6e7928c5cc8852 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 11 Aug 2026 21:58:19 +0200 Subject: [PATCH 5/5] refactor(runners): adapt nested EC2 config to v1 --- .../terragrunt/environments/terragrunt.hcl | 5 +- modules/platform/ec2_deployment/README.md | 23 ++- modules/platform/ec2_deployment/main.tf | 21 +-- modules/platform/ec2_deployment/outputs.tf | 11 +- .../ec2_deployment/runner_configs_v2.tf | 148 ++++++++++-------- .../scripts/download_lambdas.sh | 9 +- .../ec2_deployment/tests/behavior.tftest.hcl | 72 +++++++-- .../tests/interface_contract.tftest.hcl | 28 ++-- .../tests/source_inventory.tftest.hcl | 1 - modules/platform/ec2_deployment/variables.tf | 8 + modules/platform/forge_runners/README.md | 4 +- modules/platform/forge_runners/ec2_runners.tf | 4 +- .../forge_runners/github_actions_job_log.tf | 2 +- modules/platform/forge_runners/locals.tf | 8 - .../forge_runners/redrive_deadletter.tf | 3 +- .../tests/interface_contract.tftest.hcl | 8 +- .../tests/source_inventory.tftest.hcl | 2 - modules/platform/forge_runners/variables.tf | 16 +- 18 files changed, 213 insertions(+), 160 deletions(-) diff --git a/examples/deployments/platform/terragrunt/environments/terragrunt.hcl b/examples/deployments/platform/terragrunt/environments/terragrunt.hcl index aa263a735..f07c1d422 100644 --- a/examples/deployments/platform/terragrunt/environments/terragrunt.hcl +++ b/examples/deployments/platform/terragrunt/environments/terragrunt.hcl @@ -1,8 +1,5 @@ locals { - # Draft dependency on terraform-aws-github-runner PR #5260. Replace this - # commit with the first release that includes multi_runner_config_v2 and the - # EC2 v2 Lambda artifacts. - github_runner_module_source = get_env("GITHUB_RUNNER_MODULE_SOURCE", "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=961c1208a3831d19af8c0cfb43a7ed8b2d81e34b") + github_runner_module_source = get_env("GITHUB_RUNNER_MODULE_SOURCE", "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=v7.10.1") } generate "github_runner_source_override" { diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index 6a8a8b9a7..ed38f2d2f 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -5,9 +5,11 @@ This module deploys Forge EC2 runner pools through the upstream ## Why This Module Exists -The nested EC2 v2 contract lets each runner lane configure the complete +The nested, v2-compatible EC2 input lets each runner lane configure the upstream EC2 provider object while sharing Forge's webhook and runner control -plane. EC2 supports custom AMIs, macOS/Windows, dedicated hosts, and larger +plane. Forge currently translates that input to the released v1 +`multi_runner_config`, so this phase does not enable upstream's experimental v2 +runtime. EC2 supports custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## What It Manages @@ -21,19 +23,17 @@ hardware profiles. - This is a breaking input migration: every `runner_specs` entry must contain `compute_provider.ec2`. The legacy flat EC2 shape is not accepted. -- The EC2 block mirrors the upstream v2 nested contract. Forge still owns AMI +- The EC2 block mirrors the upstream v2 nested contract and is adapted to the + stable upstream v1 fields. Forge still owns AMI refresh and runner instance profiles, so EC2 lanes require a non-null, module-managed `ami` block and cannot select `ami.id_ssm_parameter` or an external `instance_profile`. The scheduled refresh uses the same default AMI name filter as the upstream EC2 provider for each runner OS and architecture; values in `ami.filter` override those defaults. -- The upstream v2 path changes Terraform resource addresses from the v1 runner - modules to provider-oriented runner stacks; this module does not include an - in-place state migration. -- The upstream dependency is pinned to draft PR #5260. Until its v2 - Lambda artifacts are released, plans require matching PR-built ZIPs supplied - with `USE_CACHE` and `CACHE_PATH`. -- Every compute runner is ephemeral and is expected to register for one job and then be reaped. +- Stable v1 has no per-lane user-data debug flag, so + `user_data.debug_logging_enabled` must remain `false` during this adapter + phase. +- Every EC2 runner is ephemeral and is expected to register for one job and then be reaped. - Label sets are the API contract with tenant workflows, so exact matching matters. - Use warm pools only where startup latency justifies the idle cost. - Subnet IP capacity and EC2 capacity errors are expected operational signals, not unusual exceptions. @@ -61,7 +61,7 @@ hardware profiles. | ---- | ------ | ------- | | [ec2\_update\_runner\_ssm\_ami](#module\_ec2\_update\_runner\_ssm\_ami) | ./ec2_update_runner_ssm_ami | n/a | | [ec2\_update\_runner\_tags](#module\_ec2\_update\_runner\_tags) | ./ec2_update_runner_tags | n/a | -| [runners](#module\_runners) | git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner | 961c1208a3831d19af8c0cfb43a7ed8b2d81e34b | +| [runners](#module\_runners) | git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner | v7.10.1 | ## Resources @@ -100,7 +100,6 @@ hardware profiles. | [ec2\_runners\_ami\_name\_map](#output\_ec2\_runners\_ami\_name\_map) | Map of EC2 runner keys to the AMI names used for each runner. | | [ec2\_runners\_arn\_map](#output\_ec2\_runners\_arn\_map) | Map of EC2 runner keys to their IAM role ARNs. | | [ec2\_runners\_labels\_map](#output\_ec2\_runners\_labels\_map) | Map of EC2 runner keys to their base and extra GitHub labels. | -| [ec2\_runners\_map](#output\_ec2\_runners\_map) | Map of EC2 runner keys to their provider-specific resources. | | [event\_bus\_name](#output\_event\_bus\_name) | Name of the EventBridge event bus used by the webhook relay. | | [subnet\_cidr\_blocks](#output\_subnet\_cidr\_blocks) | Map of EC2 runner subnet IDs to their CIDR blocks. | | [webhook\_endpoint](#output\_webhook\_endpoint) | Public HTTPS endpoint URL for the GitHub Actions webhook relay. | diff --git a/modules/platform/ec2_deployment/main.tf b/modules/platform/ec2_deployment/main.tf index da284d7d2..677228a94 100644 --- a/modules/platform/ec2_deployment/main.tf +++ b/modules/platform/ec2_deployment/main.tf @@ -5,8 +5,7 @@ locals { terraform_aws_github_runner_tags = merge( var.tenant_configs.tags, { - # Immutable head of upstream draft PR #5260. - terraform-aws-github-runner-ref = "961c1208a3831d19af8c0cfb43a7ed8b2d81e34b" + terraform-aws-github-runner-ref = "v7.10.1" } ) webhook_api_gateway_access_log_format = jsonencode({ @@ -68,11 +67,7 @@ data "aws_subnet" "runner_subnet" { } data "external" "download_lambdas" { - # TODO: v2 Lambda artifacts are not released yet. Draft plans - # must set USE_CACHE/CACHE_PATH to ZIPs built from upstream PR #5260. Once a - # v2 release exists, move this ref, the module source, and the - # terraform-aws-github-runner-ref tag to that release together. - program = ["bash", "${path.module}/scripts/download_lambdas.sh", "/tmp/${var.runner_configs.prefix}/", "961c1208a3831d19af8c0cfb43a7ed8b2d81e34b", "github-aws-runners/terraform-aws-github-runner"] + program = ["bash", "${path.module}/scripts/download_lambdas.sh", "/tmp/${var.runner_configs.prefix}/", "v7.10.1", "github-aws-runners/terraform-aws-github-runner"] } # --------------------------------------------------------------------------- @@ -130,8 +125,8 @@ resource "aws_iam_policy" "runner_hooks_ssm_read" { module "runners" { - #checkov:skip=CKV_TF_1:Draft integration is pinned to the immutable upstream PR head. - source = "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=961c1208a3831d19af8c0cfb43a7ed8b2d81e34b" + #checkov:skip=CKV_TF_1:Module source uses Renovate-managed version tags; commit SHA pinning is an accepted policy tradeoff. + source = "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=v7.10.1" aws_region = var.aws_region @@ -168,11 +163,9 @@ module "runners" { runner_binaries_syncer_lambda_zip = "${data.external.download_lambdas.result.path}/runner-binaries-syncer.zip" runners_lambda_zip = "${data.external.download_lambdas.result.path}/runners.zip" - multi_runner_config = {} - - experimental = { - multi_runner_config_v2 = local.multi_runner_config_v2 - } + # Temporary compatibility boundary: Forge accepts the nested v2 EC2 input + # shape, then translates it to the released upstream v1 contract. + multi_runner_config = local.multi_runner_config_v1 depends_on = [ data.external.download_lambdas, diff --git a/modules/platform/ec2_deployment/outputs.tf b/modules/platform/ec2_deployment/outputs.tf index c2290f94a..26e11df8b 100644 --- a/modules/platform/ec2_deployment/outputs.tf +++ b/modules/platform/ec2_deployment/outputs.tf @@ -3,18 +3,9 @@ output "webhook_endpoint" { description = "Public HTTPS endpoint URL for the GitHub Actions webhook relay." } -output "ec2_runners_map" { - value = { - for runner_key in keys(local.ec2_runner_configs) : - runner_key => module.runners.runners_map_v2[runner_key].provider.ec2 - } - description = "Map of EC2 runner keys to their provider-specific resources." -} - output "ec2_runners_arn_map" { value = { - for runner_key in keys(local.ec2_runner_configs) : - runner_key => module.runners.runners_map_v2[runner_key].runner.role.arn + for runner_key, runner in module.runners.runners_map : runner_key => runner.role_runner[0].arn } description = "Map of EC2 runner keys to their IAM role ARNs." } diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf index f4b27d485..9e0da1511 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -28,11 +28,6 @@ locals { key => concat(runner_config.runner_labels, runner_config.extra_labels) } - runner_iam_role_managed_policy_arns = { - for policy_index, policy_arn in var.runner_configs.runner_iam_role_managed_policy_arns : - "forge-${policy_index}" => policy_arn - } - forge_ec2_log_files = { for key, runner_config in local.ec2_runner_configs : key => concat( @@ -106,74 +101,91 @@ locals { ) } - multi_runner_config_v2 = { - for key, runner_config in var.runner_configs.runner_specs : + # Keep Forge's public input aligned with the nested v2 EC2 contract while + # the upstream module remains on its stable v1 multi_runner_config path. + multi_runner_config_v1 = { + for key, runner_config in local.ec2_runner_configs : key => { - runner = { - os = runner_config.runner_os - architecture = runner_config.runner_architecture - extra_labels = runner_config.extra_labels - group_name = var.runner_configs.runner_group_name - run_as = runner_config.runner_user - maximum_count = runner_config.max_instances - ephemeral = true - hooks = { - job_started = templatefile( - "${local.user_data_prefix}/hook_job_started_${runner_config.runner_os}.tftpl", - { - param_name = aws_ssm_parameter.hook_job_started[runner_config.runner_os].name - region = var.aws_region - } - ) - job_completed = templatefile( - "${local.user_data_prefix}/hook_job_completed_${runner_config.runner_os}.tftpl", - { - param_name = aws_ssm_parameter.hook_job_completed[runner_config.runner_os].name - region = var.aws_region - } - ) - } - iam = { - managed_policy_arns = merge( - local.runner_iam_role_managed_policy_arns, - { - forge_ec2_tags = aws_iam_policy.ec2_tags.arn - forge_runner_hooks_ssm = aws_iam_policy.runner_hooks_ssm_read.arn - }, - ) - } - } - - github = { - organization_runners = true - } + runner_config = { + runner_os = runner_config.runner_os + runner_architecture = runner_config.runner_architecture + runner_metadata_options = local.ec2_compute_provider[key].metadata_options + runner_extra_labels = runner_config.extra_labels + runner_group_name = var.runner_configs.runner_group_name + runner_run_as = runner_config.runner_user + runners_maximum_count = runner_config.max_instances + enable_ephemeral_runners = true + enable_organization_runners = true - queue = { - delay_webhook_event = 0 - job_queue_retention_in_seconds = 172800 - event_source_mapping = { - batch_size = runner_config.lambda_event_source_mapping_batch_size - maximum_batching_window_in_seconds = runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds + ami = { + filter = local.ec2_compute_provider[key].ami.filter + owners = local.ec2_compute_provider[key].ami.owners + id_ssm_parameter_arn = try(local.ec2_compute_provider[key].ami.id_ssm_parameter.arn, null) + kms_key_arn = try(local.ec2_compute_provider[key].ami.kms_key.arn, null) } - redrive_build_queue = runner_config.redrive_build_queue - } - - scale_up = { - job_queued_check_enabled = false - } - scale_down = { - schedule_expression = "cron(*/5 * * * ? *)" - minimum_running_time_in_minutes = runner_config.min_run_time - } + block_device_mappings = local.ec2_compute_provider[key].block_device_mappings + create_service_linked_role_spot = local.ec2_compute_provider[key].create_service_linked_role_spot + credit_specification = local.ec2_compute_provider[key].credit_specification + ebs_optimized = local.ec2_compute_provider[key].ebs_optimized + enable_cloudwatch_agent = local.ec2_compute_provider[key].cloudwatch_agent.enabled + cloudwatch_config = local.ec2_compute_provider[key].cloudwatch_agent.config + enable_runner_binaries_syncer = local.ec2_compute_provider[key].binaries_syncer.enabled + enable_runner_detailed_monitoring = local.ec2_compute_provider[key].detailed_monitoring_enabled + enable_ssm_on_runners = local.ec2_compute_provider[key].ssm_enabled + enable_userdata = local.ec2_compute_provider[key].user_data.enabled + userdata_template = local.ec2_compute_provider[key].user_data.template + userdata_content = local.ec2_compute_provider[key].user_data.content + userdata_pre_install = local.ec2_compute_provider[key].user_data.pre_install + userdata_post_install = local.ec2_compute_provider[key].user_data.post_install + instance_allocation_strategy = local.ec2_compute_provider[key].instance_allocation_strategy + instance_max_spot_price = local.ec2_compute_provider[key].instance_max_spot_price + instance_target_capacity_type = local.ec2_compute_provider[key].instance_target_capacity_type + instance_type_priorities = local.ec2_compute_provider[key].instance_type_priorities + instance_types = local.ec2_compute_provider[key].instance_types + runner_additional_security_group_ids = local.ec2_compute_provider[key].additional_security_group_ids + enable_on_demand_failover_for_errors = local.ec2_compute_provider[key].enable_on_demand_failover_for_errors + scale_errors = local.ec2_compute_provider[key].scale_errors + subnet_ids = local.ec2_compute_provider[key].subnet_ids + vpc_id = local.ec2_compute_provider[key].vpc_id + cpu_options = local.ec2_compute_provider[key].cpu_options + placement = local.ec2_compute_provider[key].placement + license_specifications = local.ec2_compute_provider[key].license_specifications + use_dedicated_host = local.ec2_compute_provider[key].use_dedicated_host + runner_log_files = local.ec2_compute_provider[key].log_files + runner_ec2_tags = local.ec2_compute_provider[key].tags - pool = { - config = runner_config.pool_config - runner_owner = var.runner_configs.ghes_org - } + delay_webhook_event = 0 + job_queue_retention_in_seconds = 172800 + lambda_event_source_mapping_batch_size = runner_config.lambda_event_source_mapping_batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds + enable_job_queued_check = false + scale_down_schedule_expression = "cron(*/5 * * * ? *)" + minimum_running_time_in_minutes = runner_config.min_run_time + pool_config = runner_config.pool_config + pool_runner_owner = var.runner_configs.ghes_org - compute_provider = { - ec2 = local.ec2_compute_provider[key] + runner_hook_job_started = templatefile( + "${local.user_data_prefix}/hook_job_started_${runner_config.runner_os}.tftpl", + { + param_name = aws_ssm_parameter.hook_job_started[runner_config.runner_os].name + region = var.aws_region + } + ) + runner_hook_job_completed = templatefile( + "${local.user_data_prefix}/hook_job_completed_${runner_config.runner_os}.tftpl", + { + param_name = aws_ssm_parameter.hook_job_completed[runner_config.runner_os].name + region = var.aws_region + } + ) + runner_iam_role_managed_policy_arns = concat( + var.runner_configs.runner_iam_role_managed_policy_arns, + [ + aws_iam_policy.ec2_tags.arn, + aws_iam_policy.runner_hooks_ssm_read.arn, + ], + ) } matcherConfig = { @@ -190,6 +202,8 @@ locals { enableDynamicLabels = runner_config.enable_dynamic_labels awsDynamicLabelsPolicy = runner_config.aws_dynamic_labels_policy } + + redrive_build_queue = runner_config.redrive_build_queue } } } diff --git a/modules/platform/ec2_deployment/scripts/download_lambdas.sh b/modules/platform/ec2_deployment/scripts/download_lambdas.sh index cd5b8d0b6..cb65c1734 100755 --- a/modules/platform/ec2_deployment/scripts/download_lambdas.sh +++ b/modules/platform/ec2_deployment/scripts/download_lambdas.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -ex +set -x if [ "$#" -lt 3 ]; then echo "Usage: $0 " @@ -24,11 +24,4 @@ else wget --no-verbose -P "$DOWNLOAD_PATH" "https://github.com/${REPO}/releases/download/${VERSION}/webhook.zip" fi -for lambda_zip in runner-binaries-syncer runners webhook; do - if [ ! -f "${DOWNLOAD_PATH}/${lambda_zip}.zip" ]; then - echo "Missing ${DOWNLOAD_PATH}/${lambda_zip}.zip for terraform-aws-github-runner ${VERSION}." >&2 - exit 1 - fi -done - echo -n "{\"version\":\"${VERSION}\",\"path\":\"${DOWNLOAD_PATH}\",\"repo\":\"${REPO}\"}" diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index 608f51143..f3eaa7306 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -138,19 +138,44 @@ variables { runner_user = "ec2-user" compute_provider = { ec2 = { + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" + } ami = { filter = { name = ["forge-*"] state = ["available"] } owners = ["123456789012"] + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + } + } + cloudwatch_agent = { + enabled = true + config = "{\"agent\":{}}" } + binaries_syncer = { + enabled = false + } + detailed_monitoring_enabled = true ebs_optimized = true + instance_allocation_strategy = "prioritized" + instance_type_priorities = { "m7i.large" = 1 } instance_types = ["m7i.large"] instance_target_capacity_type = "on-demand" + additional_security_group_ids = ["sg-runner"] + scale_errors = ["InsufficientInstanceCapacity"] + ssm_enabled = true subnet_ids = ["subnet-override"] + tags = { Lane = "ec2" } user_data = { - enabled = true + enabled = true + pre_install = "caller-pre" + post_install = "caller-post" } block_device_mappings = [{ delete_on_termination = true @@ -171,7 +196,7 @@ variables { } } -run "ec2_v2_plan" { +run "ec2_v2_input_v1_adapter_plan" { command = plan plan_options { @@ -187,8 +212,8 @@ run "ec2_v2_plan" { } assert { - condition = toset(keys(local.multi_runner_config_v2)) == toset(["ec2"]) - error_message = "The upstream v2 map must preserve every EC2 lane key." + condition = toset(keys(local.multi_runner_config_v1)) == toset(["ec2"]) + error_message = "The stable v1 adapter must preserve every EC2 lane key." } assert { @@ -198,12 +223,37 @@ run "ec2_v2_plan" { assert { condition = ( - local.multi_runner_config_v2.ec2.compute_provider.ec2 != null - && tolist(local.multi_runner_config_v2.ec2.compute_provider.ec2.ami.filter.name) == tolist(["forge-*"]) - && local.multi_runner_config_v2.ec2.compute_provider.ec2.ami.id_ssm_parameter == null - && local.multi_runner_config_v2.ec2.compute_provider.ec2.ebs_optimized + tolist(local.multi_runner_config_v1.ec2.runner_config.ami.filter.name) == tolist(["forge-*"]) + && local.multi_runner_config_v1.ec2.runner_config.ami.id_ssm_parameter_arn == null + && local.multi_runner_config_v1.ec2.runner_config.ami.kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + && local.multi_runner_config_v1.ec2.runner_config.ebs_optimized + ) + error_message = "The v1 adapter must flatten the nested EC2 AMI and fleet configuration." + } + + assert { + condition = ( + local.multi_runner_config_v1.ec2.runner_config.runner_metadata_options.http_tokens == "optional" + && local.multi_runner_config_v1.ec2.runner_config.runner_metadata_options.http_put_response_hop_limit == 2 + && local.multi_runner_config_v1.ec2.runner_config.enable_cloudwatch_agent + && local.multi_runner_config_v1.ec2.runner_config.cloudwatch_config == "{\"agent\":{}}" + && !local.multi_runner_config_v1.ec2.runner_config.enable_runner_binaries_syncer + && local.multi_runner_config_v1.ec2.runner_config.enable_runner_detailed_monitoring + && local.multi_runner_config_v1.ec2.runner_config.enable_ssm_on_runners + && tolist(local.multi_runner_config_v1.ec2.runner_config.runner_additional_security_group_ids) == tolist(["sg-runner"]) + ) + error_message = "The v1 adapter must preserve nested EC2 bootstrap, metadata, and networking settings." + } + + assert { + condition = ( + local.multi_runner_config_v1.ec2.runner_config.userdata_pre_install == "caller-pre" + && startswith(local.multi_runner_config_v1.ec2.runner_config.userdata_post_install, "caller-post\n") + && length(local.multi_runner_config_v1.ec2.runner_config.runner_log_files) == 4 + && local.multi_runner_config_v1.ec2.runner_config.runner_ec2_tags.Environment == "test" + && local.multi_runner_config_v1.ec2.runner_config.runner_ec2_tags.Lane == "ec2" ) - error_message = "The v2 translation must preserve the nested EC2 provider configuration." + error_message = "The v1 adapter must retain Forge user-data, logging, and tag overlays." } assert { @@ -217,8 +267,8 @@ run "ec2_v2_plan" { assert { condition = ( - length(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers) == 1 - && tolist(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) + length(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers) == 1 + && tolist(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) ) error_message = "Empty extra labels must retain the base label matcher." } diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index 82cc7fd6b..0c9db034e 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -17,7 +17,6 @@ run "platform_ec2_deployment_interface_contract" { "ec2_runners_ami_name_map", "ec2_runners_arn_map", "ec2_runners_labels_map", - "ec2_runners_map", "event_bus_name", "subnet_cidr_blocks", "webhook_endpoint", @@ -153,6 +152,8 @@ run "platform_ec2_deployment_interface_contract" { "length(runner_config.compute_provider.ec2.ami[*]) == 1", "&& try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false)", "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"", + "!runner_config.compute_provider.ec2.user_data.debug_logging_enabled", + "error_message = \"Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active.\"", "length(runner_config.compute_provider.ec2.instance_profile[*]) == 0", "error_message = \"Forge EC2 runner_specs do not support an external instance_profile.\"", "active_ec2_subnet_ids = toset(flatten([", @@ -171,17 +172,20 @@ run "platform_ec2_deployment_interface_contract" { "runner_config.compute_provider.ec2,", "log_files = coalesce(runner_config.compute_provider.ec2.log_files, local.forge_ec2_log_files[key])", "tags = merge(var.tenant_configs.tags, runner_config.compute_provider.ec2.tags)", - "managed_policy_arns = merge(", - "local.runner_iam_role_managed_policy_arns,", - "forge_ec2_tags = aws_iam_policy.ec2_tags.arn", - "forge_runner_hooks_ssm = aws_iam_policy.runner_hooks_ssm_read.arn", + "multi_runner_config_v1 = {", + "runner_metadata_options = local.ec2_compute_provider[key].metadata_options", + "id_ssm_parameter_arn = try(local.ec2_compute_provider[key].ami.id_ssm_parameter.arn, null)", + "kms_key_arn = try(local.ec2_compute_provider[key].ami.kms_key.arn, null)", + "enable_cloudwatch_agent = local.ec2_compute_provider[key].cloudwatch_agent.enabled", + "enable_userdata = local.ec2_compute_provider[key].user_data.enabled", + "runner_additional_security_group_ids = local.ec2_compute_provider[key].additional_security_group_ids", + "runner_log_files = local.ec2_compute_provider[key].log_files", + "runner_ec2_tags = local.ec2_compute_provider[key].tags", + "runner_iam_role_managed_policy_arns = concat(", "labelMatchers = length(runner_config.extra_labels) == 0 ? [runner_config.runner_labels] : concat(", - "multi_runner_config = {}", - "multi_runner_config_v2 = local.multi_runner_config_v2", - "output \"ec2_runners_map\"", - "module.runners.runners_map_v2[runner_key].provider.ec2", + "multi_runner_config = local.multi_runner_config_v1", "output \"ec2_runners_arn_map\"", - "module.runners.runners_map_v2[runner_key].runner.role.arn", + "for runner_key, runner in module.runners.runners_map : runner_key => runner.role_runner[0].arn", "output \"ec2_runners_ami_name_map\"", "data.aws_ami.runner_ami[runner_key].name", "output \"ec2_runners_labels_map\"", @@ -222,8 +226,8 @@ run "platform_ec2_deployment_interface_contract" { assert { condition = ( output.expected_input_variable_count == 4 - && output.expected_output_value_count == 7 - && output.expected_interface_literal_count == 168 + && output.expected_output_value_count == 6 + && output.expected_interface_literal_count == 173 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl index f96ce3fb9..8cb931b75 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -28,7 +28,6 @@ run "platform_ec2_deployment_contract" { "data \"aws_iam_policy_document\" \"runner_hooks_ssm_read\"", "data \"aws_iam_policy_document\" \"ec2_tags\"", "output \"webhook_endpoint\"", - "output \"ec2_runners_map\"", "output \"ec2_runners_arn_map\"", "output \"ec2_runners_ami_name_map\"", "output \"ec2_runners_labels_map\"", diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index 121c58033..60e868dfd 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -164,6 +164,14 @@ variable "runner_configs" { error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." } + validation { + condition = alltrue([ + for runner_config in values(var.runner_configs.runner_specs) : + !runner_config.compute_provider.ec2.user_data.debug_logging_enabled + ]) + error_message = "Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active." + } + validation { condition = alltrue([ for runner_config in values(var.runner_configs.runner_specs) : diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index 8b52ecfcf..21883ec28 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -21,6 +21,8 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived - EC2 and ARC lanes can be enabled for the same tenant; workflows choose by labels. - `ec2_deployment_specs.runner_specs` now uses a breaking nested EC2 v2 shape; every entry must configure `compute_provider.ec2`. +- `ec2_deployment` translates that public shape to the released upstream v1 + `multi_runner_config`; this phase does not enable the experimental v2 runtime. - Changing GitHub App or runner-group settings can affect job routing immediately. ## Deployment version inventory @@ -116,7 +118,7 @@ tag with the expected ref in `release_versions.yml`. | [aws\_region](#input\_aws\_region) | AWS region where Forge runners and supporting infrastructure are deployed. | `string` | n/a | yes | | [default\_tags](#input\_default\_tags) | A map of tags to apply to resources. | `map(string)` | n/a | yes | | [deployment\_config](#input\_deployment\_config) | High-level deployment configuration for a Forge runner installation.

Top-level fields:
- deployment\_prefix: Prefix used when naming resources (for example,
log groups, KMS keys, and SSM parameters).
- env : Logical environment name (for example, dev, stage,
prod). Used for tagging and dashboards.

github\_app object:
- id : Numeric GitHub App ID.
- client\_id : OAuth client ID for the app.
- installation\_id: GitHub App installation ID for this tenant.
- name : GitHub App name, used to build URLs and logs.

github object:
- ghes\_org : GitHub organization that owns the repos where
runners will be used.
- ghes\_url : GitHub.com or GHES base URL. Empty string implies
public github.com.
- repository\_selection: Scope for runners (all or selected repositories).
- runner\_group\_name : GitHub runner group to attach new runners to.

tenant object:
- name : Tenant identifier used in naming and
tagging.
- iam\_roles\_to\_assume : Optional list of IAM role ARNs that
runners are allowed to assume for workload execution.
- ecr\_registries : Optional list of ECR registry URLs that
runners may need to pull images from.
- github\_logs\_reader\_role\_arns: Optional list of IAM roles that can read
GitHub Actions logs for this tenant. |
object({
deployment_prefix = string
secret_suffix = string
env = string
github_app = object({
id = string
client_id = string
installation_id = string
name = string
})
github = object({
ghes_org = string
ghes_url = string
repository_selection = string
runner_group_name = string
})
tenant = object({
name = string
iam_roles_to_assume = optional(list(string), [])
ecr_registries = optional(list(string), [])
github_logs_reader_role_arns = optional(list(string), [])
})
})
| n/a | yes | -| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | Compute deployment configuration for GitHub Actions runners.

Top-level fields:
- lambda\_subnet\_ids: Subnets where runner-related lambdas execute.
These can be more permissive than the runner subnets.
- subnet\_ids : Default subnets for EC2 runners.
- vpc\_id : VPC that contains both runner and lambda subnets.
- runner\_specs : Map of EC2 runner lanes.

runner\_specs[*] object fields:
- runner\_labels : Base GitHub labels applied to jobs for this pool.
- runner\_os : Runner operating system (for example, linux).
- runner\_architecture: CPU architecture (for example, x86\_64 or arm64).
- extra\_labels : Additional GitHub labels that further specialize
this runner pool.
- enable\_dynamic\_labels: Enables dynamic `ghr-` labels for this runner
pool.
- aws\_dynamic\_labels\_policy: Optional policy for `ghr-ec2-*` labels for
this runner pool.
- lambda\_event\_source\_mapping\_batch\_size: Optional maximum number of queued
jobs passed to the scale-up Lambda per invocation.
- lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: Optional
maximum time to collect queued jobs before invoking the
scale-up Lambda.
- redrive\_build\_queue: Optional dead-letter queue redrive configuration.
Controls whether redrive is enabled and how many times a
message can be received before moving to the dead-letter
queue.
- max\_instances : Maximum number of runners in this pool.
- min\_run\_time : Minimum job run time (in minutes) before a runner
is eligible for scale-down.
- pool\_config : List of pool size schedules (size + cron expression
and optional time zone) controlling baseline capacity.
- runner\_user : OS user under which the GitHub runner process runs.
- compute\_provider: Nested upstream EC2 provider configuration.

compute\_provider.ec2 fields:
- ami : Upstream-compatible EC2 AMI configuration.
Forge requires a module-managed AMI block; null and
external AMI parameter ownership are unsupported.
- metadata\_options: EC2 instance metadata service configuration.
- block\_device\_mappings: EBS mappings for runner instances.
- cloudwatch\_agent/binaries\_syncer/user\_data: Runner bootstrap configuration.
- instance\_types and allocation fields: EC2 Fleet capacity configuration.
- vpc\_id/subnet\_ids/additional\_security\_group\_ids: Per-lane networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Upstream contract field reserved for future Forge support.
- log\_files/tags : EC2 logging and resource tags. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
})
})
}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | EC2 deployment configuration for GitHub Actions runners. The public runner
shape follows the nested v2 EC2 contract and is translated internally to the
released upstream v1 multi\_runner\_config interface.

Top-level fields:
- lambda\_subnet\_ids: Subnets where runner-related lambdas execute.
These can be more permissive than the runner subnets.
- subnet\_ids : Default subnets for EC2 runners.
- vpc\_id : VPC that contains both runner and lambda subnets.
- runner\_specs : Map of EC2 runner lanes.

runner\_specs[*] object fields:
- runner\_labels : Base GitHub labels applied to jobs for this pool.
- runner\_os : Runner operating system (for example, linux).
- runner\_architecture: CPU architecture (for example, x86\_64 or arm64).
- extra\_labels : Additional GitHub labels that further specialize
this runner pool.
- enable\_dynamic\_labels: Enables dynamic `ghr-` labels for this runner
pool.
- aws\_dynamic\_labels\_policy: Optional policy for `ghr-ec2-*` labels for
this runner pool.
- lambda\_event\_source\_mapping\_batch\_size: Optional maximum number of queued
jobs passed to the scale-up Lambda per invocation.
- lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: Optional
maximum time to collect queued jobs before invoking the
scale-up Lambda.
- redrive\_build\_queue: Optional dead-letter queue redrive configuration.
Controls whether redrive is enabled and how many times a
message can be received before moving to the dead-letter
queue.
- max\_instances : Maximum number of runners in this pool.
- min\_run\_time : Minimum job run time (in minutes) before a runner
is eligible for scale-down.
- pool\_config : List of pool size schedules (size + cron expression
and optional time zone) controlling baseline capacity.
- runner\_user : OS user under which the GitHub runner process runs.
- compute\_provider: Nested v2-compatible EC2 provider configuration.

compute\_provider.ec2 fields:
- ami : Upstream-compatible EC2 AMI configuration.
Forge requires a module-managed AMI block; null and
external AMI parameter ownership are unsupported.
- metadata\_options: EC2 instance metadata service configuration.
- block\_device\_mappings: EBS mappings for runner instances.
- cloudwatch\_agent/binaries\_syncer/user\_data: Runner bootstrap configuration.
user\_data.debug\_logging\_enabled must remain false while
the stable upstream v1 adapter is active.
- instance\_types and allocation fields: EC2 Fleet capacity configuration.
- vpc\_id/subnet\_ids/additional\_security\_group\_ids: Per-lane networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Upstream contract field reserved for future Forge support.
- log\_files/tags : EC2 logging and resource tags. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
})
})
}))
})
| n/a | yes | | [github\_webhook\_relay](#input\_github\_webhook\_relay) | Configuration for the (optional) webhook relay source module.
If enabled=true we provision the API Gateway + source EventBridge forwarding rule.
destination\_event\_bus\_name must already exist or be created in the destination account (or via the destination submodule run there). |
object({
enabled = bool
destination_account_id = optional(string)
destination_event_bus_name = optional(string)
destination_region = optional(string)
destination_reader_role_arn = optional(string)
})
|
{
"destination_account_id": "",
"destination_event_bus_name": "",
"destination_reader_role_arn": "",
"destination_region": "",
"enabled": false
}
| no | | [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | n/a | yes | | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Logging retention period in days. | `string` | n/a | yes | diff --git a/modules/platform/forge_runners/ec2_runners.tf b/modules/platform/forge_runners/ec2_runners.tf index bfe052de8..ab3e04b05 100644 --- a/modules/platform/forge_runners/ec2_runners.tf +++ b/modules/platform/forge_runners/ec2_runners.tf @@ -2,12 +2,12 @@ # For generating a webhook secret. Apparently this is a cryptographically secure # PRNG. resource "random_id" "random" { - count = local.has_active_runners ? 1 : 0 + count = length(var.ec2_deployment_specs.runner_specs) > 0 ? 1 : 0 byte_length = 20 } module "ec2_runners" { - count = local.has_active_runners ? 1 : 0 + count = length(var.ec2_deployment_specs.runner_specs) > 0 ? 1 : 0 # Using multi-runner example as a baseline. source = "../ec2_deployment" diff --git a/modules/platform/forge_runners/github_actions_job_log.tf b/modules/platform/forge_runners/github_actions_job_log.tf index c20b3af11..5a31aba66 100644 --- a/modules/platform/forge_runners/github_actions_job_log.tf +++ b/modules/platform/forge_runners/github_actions_job_log.tf @@ -1,5 +1,5 @@ module "github_actions_job_logs" { - count = local.has_active_runners ? 1 : 0 + count = length(var.ec2_deployment_specs.runner_specs) > 0 ? 1 : 0 source = "./github_actions_job_logs" providers = { diff --git a/modules/platform/forge_runners/locals.tf b/modules/platform/forge_runners/locals.tf index 4d7fb35ed..b7980b2c4 100644 --- a/modules/platform/forge_runners/locals.tf +++ b/modules/platform/forge_runners/locals.tf @@ -1,12 +1,4 @@ locals { - active_runner_keys = toset(keys(var.ec2_deployment_specs.runner_specs)) - has_active_runners = length(local.active_runner_keys) > 0 - - active_redrive_runner_keys = toset([ - for runner_key, runner_config in var.ec2_deployment_specs.runner_specs : runner_key - if runner_config.redrive_build_queue.enabled - ]) - runner_iam_role_managed_policy_arns = concat( # If the policy exists, include it, otherwise skip it length(var.deployment_config.tenant.iam_roles_to_assume) > 0 ? [aws_iam_policy.role_assumption_for_forge_runners[0].arn] : [], diff --git a/modules/platform/forge_runners/redrive_deadletter.tf b/modules/platform/forge_runners/redrive_deadletter.tf index b862a054a..35ee1a787 100644 --- a/modules/platform/forge_runners/redrive_deadletter.tf +++ b/modules/platform/forge_runners/redrive_deadletter.tf @@ -3,7 +3,6 @@ locals { } module "redrive_deadletter" { - count = local.has_active_runners ? 1 : 0 source = "./redrive_deadletter" providers = { @@ -17,7 +16,7 @@ module "redrive_deadletter" { sqs_map = merge( { - for key in local.active_redrive_runner_keys : + for key in keys(var.ec2_deployment_specs.runner_specs) : key => { dlq = "${local.sqs_prefix_arn}:${var.deployment_config.deployment_prefix}-${key}-queued-builds_dead_letter" main = "${local.sqs_prefix_arn}:${var.deployment_config.deployment_prefix}-${key}-queued-builds" diff --git a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl index 168362968..8efc70a3a 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -241,9 +241,11 @@ run "platform_forge_runners_interface_contract" { "length(runner_config.compute_provider.ec2.ami[*]) == 1", "&& try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false)", "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"", + "!runner_config.compute_provider.ec2.user_data.debug_logging_enabled", + "error_message = \"Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active.\"", "length(runner_config.compute_provider.ec2.instance_profile[*]) == 0", "error_message = \"Forge EC2 runner_specs do not support an external instance_profile.\"", - "Compute deployment configuration for GitHub Actions runners.", + "EC2 deployment configuration for GitHub Actions runners. The public runner", "- lambda_subnet_ids: Subnets where runner-related lambdas execute.", "These can be more permissive than the runner subnets.", "- subnet_ids : Default subnets for EC2 runners.", @@ -252,7 +254,7 @@ run "platform_forge_runners_interface_contract" { "- runner_labels : Base GitHub labels applied to jobs for this pool.", "- runner_os : Runner operating system (for example, linux).", "- redrive_build_queue: Optional dead-letter queue redrive configuration.", - "- compute_provider: Nested upstream EC2 provider configuration.", + "- compute_provider: Nested v2-compatible EC2 provider configuration.", "compute_provider.ec2 fields:", "- ami : Upstream-compatible EC2 AMI configuration.", "variable \"github_webhook_relay\"", @@ -341,7 +343,7 @@ run "platform_forge_runners_interface_contract" { condition = ( output.expected_input_variable_count == 10 && output.expected_output_value_count == 5 - && output.expected_interface_literal_count == 282 + && output.expected_interface_literal_count == 284 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl index 5453a9a27..e41c3d249 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -17,12 +17,10 @@ run "platform_forge_runners_contract" { "module \"github_webhook_relay\"", "module \"redrive_deadletter\"", "resource \"random_id\" \"random\"", - "active_runner_keys = toset(keys(var.ec2_deployment_specs.runner_specs))", "runner_specs = var.ec2_deployment_specs.runner_specs", "compute_provider = object({", "ec2 = object({", "scale_errors = optional(list(string), [", - "for key in local.active_redrive_runner_keys :", "try(module.ec2_runners[0].ec2_runners_arn_map, {}),", "resource \"aws_iam_policy\" \"role_assumption_for_forge_runners\"", "resource \"aws_iam_policy\" \"ecr_access_for_ec2_instances\"", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index 883f80cb9..acc8fcbc1 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -160,6 +160,14 @@ variable "ec2_deployment_specs" { error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." } + validation { + condition = alltrue([ + for runner_config in values(var.ec2_deployment_specs.runner_specs) : + !runner_config.compute_provider.ec2.user_data.debug_logging_enabled + ]) + error_message = "Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active." + } + validation { condition = alltrue([ for runner_config in values(var.ec2_deployment_specs.runner_specs) : @@ -169,7 +177,9 @@ variable "ec2_deployment_specs" { } description = <<-EOT - Compute deployment configuration for GitHub Actions runners. + EC2 deployment configuration for GitHub Actions runners. The public runner + shape follows the nested v2 EC2 contract and is translated internally to the + released upstream v1 multi_runner_config interface. Top-level fields: - lambda_subnet_ids: Subnets where runner-related lambdas execute. @@ -203,7 +213,7 @@ variable "ec2_deployment_specs" { - pool_config : List of pool size schedules (size + cron expression and optional time zone) controlling baseline capacity. - runner_user : OS user under which the GitHub runner process runs. - - compute_provider: Nested upstream EC2 provider configuration. + - compute_provider: Nested v2-compatible EC2 provider configuration. compute_provider.ec2 fields: - ami : Upstream-compatible EC2 AMI configuration. @@ -212,6 +222,8 @@ variable "ec2_deployment_specs" { - metadata_options: EC2 instance metadata service configuration. - block_device_mappings: EBS mappings for runner instances. - cloudwatch_agent/binaries_syncer/user_data: Runner bootstrap configuration. + user_data.debug_logging_enabled must remain false while + the stable upstream v1 adapter is active. - instance_types and allocation fields: EC2 Fleet capacity configuration. - vpc_id/subnet_ids/additional_security_group_ids: Per-lane networking. - cpu_options/placement/license_specifications: EC2 launch-template options.