From d8cbff10675a2ad596fa79be3674c821305672f5 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 13 Aug 2026 21:36:03 +0200 Subject: [PATCH 1/8] chore(runners): use experimental multi-runner v2 ref --- modules/platform/ec2_deployment/README.md | 2 +- modules/platform/ec2_deployment/main.tf | 4 ++-- .../platform/ec2_deployment/tests/source_inventory.tftest.hcl | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index d73a95a7..9611ebce 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -68,7 +68,7 @@ dedicated hosts, and larger 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 | v7.10.1 | +| [runners](#module\_runners) | git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner | experimental-multi-runner-config-v2-20260805 | ## Resources diff --git a/modules/platform/ec2_deployment/main.tf b/modules/platform/ec2_deployment/main.tf index 677228a9..7a955832 100644 --- a/modules/platform/ec2_deployment/main.tf +++ b/modules/platform/ec2_deployment/main.tf @@ -125,8 +125,8 @@ resource "aws_iam_policy" "runner_hooks_ssm_read" { 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:Temporary upstream experimental ref is required to validate multi-runner v2 compatibility before a release tag exists. + source = "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=experimental-multi-runner-config-v2-20260805" aws_region = var.aws_region diff --git a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl index 30b83268..2cddd213 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -11,7 +11,7 @@ run "platform_ec2_deployment_contract" { "module \"ec2_update_runner_ssm_ami\"", "module \"ec2_update_runner_tags\"", "module \"runners\"", - "source = \"git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=v7.10.1\"", + "source = \"git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=experimental-multi-runner-config-v2-20260805\"", "multi_runner_config = local.multi_runner_config_v1", "resource \"aws_cloudwatch_log_group\" \"webhook_api_gateway_access\"", "resource \"aws_kms_key\" \"github\"", From bfe54bff2fb291381533e80cf87b56260659140f Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 13 Aug 2026 22:01:50 +0200 Subject: [PATCH 2/8] feat(runners): use experimental multi-runner v2 config --- modules/platform/ec2_deployment/README.md | 47 ++-- modules/platform/ec2_deployment/main.tf | 7 +- modules/platform/ec2_deployment/outputs.tf | 5 +- .../ec2_deployment/runner_configs_v2.tf | 128 +++-------- .../ec2_deployment/tests/behavior.tftest.hcl | 216 +++++++++++------- .../tests/interface_contract.tftest.hcl | 55 ++--- .../tests/source_inventory.tftest.hcl | 4 +- modules/platform/ec2_deployment/variables.tf | 50 ---- modules/platform/forge_runners/README.md | 12 +- .../tests/interface_contract.tftest.hcl | 23 +- modules/platform/forge_runners/variables.tf | 73 +----- 11 files changed, 244 insertions(+), 376 deletions(-) diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index 9611ebce..11f148c0 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -5,12 +5,12 @@ This module deploys Forge EC2 runner pools through the upstream ## Why This Module Exists -The v2-compatible input lets each runner lane configure the common runner, -GitHub, queue, scaling, pool, retry, matcher, and EC2 provider blocks while -sharing Forge's webhook and runner control plane. Forge 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. +The v2 input lets each runner lane configure the common runner, GitHub, queue, +scaling, pool, retry, SSM, observability, matcher, and EC2 provider blocks while +sharing Forge's webhook and runner control plane. Forge enriches each lane with +its required hooks, IAM policies, bootstrap content, logging, tags, and KMS key, +then passes it to upstream's experimental `multi_runner_config_v2`. EC2 supports +custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## What It Manages @@ -24,23 +24,22 @@ dedicated hosts, and larger hardware profiles. - This is a breaking input migration: every `runner_specs` entry uses the full nested v2 shape and must contain `runner`, `matcherConfig`, and `compute_provider.ec2`. The legacy flat EC2 shape is not accepted. -- The common and EC2 blocks mirror the upstream v2 contract and are adapted to - 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. -- Stable v1 has no per-lane user-data debug flag, so - `user_data.debug_logging_enabled` must remain `false` during this adapter - phase. -- Stable v1 also cannot represent v2's per-component tag maps, per-lane SSM KMS - key, per-lane IAM trust/path/boundary settings, or custom job-retry Lambda - concurrency. Compatibility validations reject those values instead of - silently dropping them. `compute_provider.ec2.tags` remains supported. -- Preserve runner-lane map keys when migrating configuration; those keys are - used by v1 `for_each` resources and therefore define Terraform state - addresses. +- Forge still owns scheduled AMI refresh, so EC2 lanes require a non-null, + module-managed `ami` block and cannot select `ami.id_ssm_parameter`. The + 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. +- External runner roles and instance profiles are supported as a pair. Their + owner must provision Forge's EC2-tag and hook-SSM permissions because the + module does not modify externally managed IAM resources. +- The upstream v2 ref is experimental and mutable. It moves lane resources from + `module.runners` to `module.runner_stacks` and currently provides no Terraform + `moved` blocks. Use it for new state only; existing deployments need a + separately reviewed state migration to avoid create/destroy plans and name + collisions. Preserving lane keys alone does not preserve internal addresses. +- Forge currently supplies the v7.10.1 Lambda release artifacts to the + experimental Terraform graph. Keep rollout gated until artifact compatibility + is verified or exact-ref artifacts are published. - 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. @@ -59,7 +58,7 @@ dedicated hosts, and larger hardware profiles. | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +| [aws](#provider\_aws) | 6.59.0 | | [external](#provider\_external) | 2.4.0 | ## Modules diff --git a/modules/platform/ec2_deployment/main.tf b/modules/platform/ec2_deployment/main.tf index 7a955832..f797e962 100644 --- a/modules/platform/ec2_deployment/main.tf +++ b/modules/platform/ec2_deployment/main.tf @@ -163,9 +163,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" - # 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 + multi_runner_config = {} + experimental = { + multi_runner_config = local.multi_runner_config + } depends_on = [ data.external.download_lambdas, diff --git a/modules/platform/ec2_deployment/outputs.tf b/modules/platform/ec2_deployment/outputs.tf index 26e11df8..fe12184d 100644 --- a/modules/platform/ec2_deployment/outputs.tf +++ b/modules/platform/ec2_deployment/outputs.tf @@ -5,7 +5,10 @@ output "webhook_endpoint" { 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, runner in module.runners.runners_map_v2 : runner_key => coalesce( + try(runner.runner.role.arn, null), + try(local.ec2_runner_configs[runner_key].runner.iam.role.arn, null), + ) } 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 a370d26b..7b397cb0 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -178,104 +178,40 @@ locals { ) } - # 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 = { + # Preserve Forge-managed runner hooks, policies, bootstrap content, logging, + # and tags while passing the public nested contract directly to upstream v2. + multi_runner_config = { for key, runner_config in local.ec2_runner_configs : - key => { - 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_boot_time_in_minutes = runner_config.runner.boot_time_in_minutes - runner_disable_default_labels = runner_config.runner.disable_default_labels - runner_extra_labels = runner_config.runner.extra_labels - runner_group_name = runner_config.runner.group_name - runner_name_prefix = runner_config.runner.name_prefix - runner_as_root = runner_config.runner.run_as_root - runner_run_as = runner_config.runner.run_as - runners_maximum_count = runner_config.runner.maximum_count - enable_ephemeral_runners = runner_config.runner.ephemeral - enable_jit_config = runner_config.runner.jit_config_enabled - disable_runner_autoupdate = runner_config.runner.auto_update_disabled - enable_organization_runners = runner_config.github.organization_runners - - 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) - } - - 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 - - delay_webhook_event = runner_config.queue.delay_webhook_event - job_queue_retention_in_seconds = runner_config.queue.job_queue_retention_in_seconds - lambda_event_source_mapping_batch_size = runner_config.queue.event_source_mapping.batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = runner_config.queue.event_source_mapping.maximum_batching_window_in_seconds - scale_up_reserved_concurrent_executions = runner_config.scale_up.reserved_concurrent_executions - enable_job_queued_check = runner_config.scale_up.job_queued_check_enabled - scale_down_schedule_expression = runner_config.scale_down.schedule_expression - minimum_running_time_in_minutes = runner_config.scale_down.minimum_running_time_in_minutes - idle_config = runner_config.scale_down.idle_config - pool_config = runner_config.pool.config - pool_runner_owner = runner_config.pool.runner_owner - job_retry = { - enable = runner_config.job_retry.enabled - delay_in_seconds = runner_config.job_retry.delay_in_seconds - delay_backoff = runner_config.job_retry.delay_backoff - lambda_memory_size = runner_config.job_retry.lambda.memory_size - lambda_timeout = runner_config.job_retry.lambda.timeout - max_attempts = runner_config.job_retry.max_attempts + key => merge(runner_config, { + runner = merge(runner_config.runner, { + hooks = { + job_started = local.runner_hook_job_started[key] + job_completed = local.runner_hook_job_completed[key] } - - runner_hook_job_started = local.runner_hook_job_started[key] - runner_hook_job_completed = local.runner_hook_job_completed[key] - 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, - ], - [ - for policy_name in sort(keys(runner_config.runner.iam.managed_policy_arns)) : - runner_config.runner.iam.managed_policy_arns[policy_name] - ], - ) + iam = merge(runner_config.runner.iam, { + managed_policy_arns = merge( + runner_config.runner.iam.managed_policy_arns, + runner_config.runner.iam.role == null ? merge( + { + for policy_index, policy_arn in var.runner_configs.runner_iam_role_managed_policy_arns : + "forge-config-${policy_index}" => policy_arn + }, + { + forge_ec2_tags = aws_iam_policy.ec2_tags.arn + forge_runner_hooks_ssm_read = aws_iam_policy.runner_hooks_ssm_read.arn + }, + ) : {}, + ) + }) + }) + compute_provider = { + ec2 = local.ec2_compute_provider[key] } - - matcherConfig = runner_config.matcherConfig - - redrive_build_queue = runner_config.queue.redrive_build_queue - } + ssm = merge(runner_config.ssm, { + kms_key = runner_config.ssm.kms_key == null ? { + arn = aws_kms_key.github.arn + } : runner_config.ssm.kms_key + }) + }) } } diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index 63eaa876..d53851ca 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -126,6 +126,7 @@ variables { runner_iam_role_managed_policy_arns = [] runner_specs = { ec2 = { + tags = { Scope = "entry" } runner = { os = "linux" architecture = "x64" @@ -140,6 +141,7 @@ variables { ephemeral = true jit_config_enabled = false auto_update_disabled = true + tags = { Scope = "runner" } hooks = { job_started = "echo caller-started\nexit 0" job_completed = "echo caller-completed" @@ -148,11 +150,17 @@ variables { managed_policy_arns = { caller = "arn:aws:iam::123456789012:policy/caller" } + additional_trust_policy_json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + path = "/forge/" + permissions_boundary = "arn:aws:iam::123456789012:policy/boundary" } } github = { organization_runners = true } + lambda = { + tags = { Scope = "lambda" } + } queue = { delay_webhook_event = 7 job_queue_retention_in_seconds = 90000 @@ -164,10 +172,12 @@ variables { enabled = true maxReceiveCount = 4 } + tags = { Scope = "queue" } } scale_up = { reserved_concurrent_executions = 2 job_queued_check_enabled = false + tags = { Scope = "scale-up" } } scale_down = { schedule_expression = "rate(10 minutes)" @@ -178,6 +188,7 @@ variables { idleCount = 1 evictionStrategy = "newest_first" }] + tags = { Scope = "scale-down" } } pool = { config = [{ @@ -186,15 +197,32 @@ variables { size = 1 }] runner_owner = "cisco-open" + tags = { Scope = "pool" } } job_retry = { enabled = true delay_in_seconds = 120 delay_backoff = 3 max_attempts = 2 + tags = { Scope = "job-retry" } lambda = { - memory_size = 512 - timeout = 45 + memory_size = 512 + reserved_concurrent_executions = 3 + timeout = 45 + } + } + ssm = { + tags = { Scope = "ssm" } + parameters = { + tags = { Scope = "ssm-parameters" } + } + housekeeper = { + tags = { Scope = "ssm-housekeeper" } + } + } + observability = { + logs = { + tags = { Scope = "logs" } } } compute_provider = { @@ -234,9 +262,10 @@ variables { subnet_ids = ["subnet-override"] tags = { Lane = "ec2" } user_data = { - enabled = true - pre_install = "caller-pre" - post_install = "caller-post" + enabled = true + pre_install = "caller-pre" + post_install = "caller-post" + debug_logging_enabled = true } block_device_mappings = [{ delete_on_termination = true @@ -266,7 +295,7 @@ variables { } } -run "ec2_v2_input_v1_adapter_plan" { +run "ec2_v2_input_plan" { command = plan plan_options { @@ -282,8 +311,8 @@ run "ec2_v2_input_v1_adapter_plan" { } assert { - condition = toset(keys(local.multi_runner_config_v1)) == toset(["ec2"]) - error_message = "The stable v1 adapter must preserve every EC2 lane key." + condition = toset(keys(local.multi_runner_config_v2)) == toset(["ec2"]) + error_message = "The upstream v2 configuration must preserve every EC2 lane key." } assert { @@ -293,88 +322,90 @@ run "ec2_v2_input_v1_adapter_plan" { assert { condition = ( - 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 + 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.ami.kms_key.arn == "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + && local.multi_runner_config_v2.ec2.compute_provider.ec2.ebs_optimized ) - error_message = "The v1 adapter must flatten the nested EC2 AMI and fleet configuration." + error_message = "The v2 configuration must preserve 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"]) + local.multi_runner_config_v2.ec2.compute_provider.ec2.metadata_options.http_tokens == "optional" + && local.multi_runner_config_v2.ec2.compute_provider.ec2.metadata_options.http_put_response_hop_limit == 2 + && local.multi_runner_config_v2.ec2.compute_provider.ec2.cloudwatch_agent.enabled + && local.multi_runner_config_v2.ec2.compute_provider.ec2.cloudwatch_agent.config == "{\"agent\":{}}" + && !local.multi_runner_config_v2.ec2.compute_provider.ec2.binaries_syncer.enabled + && local.multi_runner_config_v2.ec2.compute_provider.ec2.detailed_monitoring_enabled + && local.multi_runner_config_v2.ec2.compute_provider.ec2.ssm_enabled + && tolist(local.multi_runner_config_v2.ec2.compute_provider.ec2.additional_security_group_ids) == tolist(["sg-runner"]) ) - error_message = "The v1 adapter must preserve nested EC2 bootstrap, metadata, and networking settings." + error_message = "The v2 configuration must preserve nested EC2 bootstrap, metadata, and networking settings." } assert { condition = ( - local.multi_runner_config_v1.ec2.runner_config.runner_boot_time_in_minutes == 7 - && local.multi_runner_config_v1.ec2.runner_config.runner_disable_default_labels - && tolist(local.multi_runner_config_v1.ec2.runner_config.runner_extra_labels) == tolist(["caller-extra"]) - && local.multi_runner_config_v1.ec2.runner_config.runner_group_name == "Forge" - && local.multi_runner_config_v1.ec2.runner_config.runner_name_prefix == "forge-" - && local.multi_runner_config_v1.ec2.runner_config.runner_as_root - && local.multi_runner_config_v1.ec2.runner_config.runner_run_as == "ec2-user" - && local.multi_runner_config_v1.ec2.runner_config.runners_maximum_count == 2 - && local.multi_runner_config_v1.ec2.runner_config.enable_ephemeral_runners - && !local.multi_runner_config_v1.ec2.runner_config.enable_jit_config - && local.multi_runner_config_v1.ec2.runner_config.disable_runner_autoupdate - && local.multi_runner_config_v1.ec2.runner_config.enable_organization_runners + local.multi_runner_config_v2.ec2.runner.boot_time_in_minutes == 7 + && local.multi_runner_config_v2.ec2.runner.disable_default_labels + && tolist(local.multi_runner_config_v2.ec2.runner.extra_labels) == tolist(["caller-extra"]) + && local.multi_runner_config_v2.ec2.runner.group_name == "Forge" + && local.multi_runner_config_v2.ec2.runner.name_prefix == "forge-" + && local.multi_runner_config_v2.ec2.runner.run_as_root + && local.multi_runner_config_v2.ec2.runner.run_as == "ec2-user" + && local.multi_runner_config_v2.ec2.runner.maximum_count == 2 + && local.multi_runner_config_v2.ec2.runner.ephemeral + && !local.multi_runner_config_v2.ec2.runner.jit_config_enabled + && local.multi_runner_config_v2.ec2.runner.auto_update_disabled + && local.multi_runner_config_v2.ec2.github.organization_runners ) - error_message = "The v1 adapter must translate the complete nested runner and GitHub blocks." + error_message = "The v2 configuration must preserve the complete nested runner and GitHub blocks." } assert { condition = ( - local.multi_runner_config_v1.ec2.runner_config.delay_webhook_event == 7 - && local.multi_runner_config_v1.ec2.runner_config.job_queue_retention_in_seconds == 90000 - && local.multi_runner_config_v1.ec2.runner_config.lambda_event_source_mapping_batch_size == 5 - && local.multi_runner_config_v1.ec2.runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds == 1 - && local.multi_runner_config_v1.ec2.runner_config.scale_up_reserved_concurrent_executions == 2 - && !local.multi_runner_config_v1.ec2.runner_config.enable_job_queued_check - && local.multi_runner_config_v1.ec2.runner_config.scale_down_schedule_expression == "rate(10 minutes)" - && local.multi_runner_config_v1.ec2.runner_config.minimum_running_time_in_minutes == 5 - && local.multi_runner_config_v1.ec2.runner_config.idle_config[0].idleCount == 1 - && local.multi_runner_config_v1.ec2.redrive_build_queue.maxReceiveCount == 4 + local.multi_runner_config_v2.ec2.queue.delay_webhook_event == 7 + && local.multi_runner_config_v2.ec2.queue.job_queue_retention_in_seconds == 90000 + && local.multi_runner_config_v2.ec2.queue.event_source_mapping.batch_size == 5 + && local.multi_runner_config_v2.ec2.queue.event_source_mapping.maximum_batching_window_in_seconds == 1 + && local.multi_runner_config_v2.ec2.scale_up.reserved_concurrent_executions == 2 + && !local.multi_runner_config_v2.ec2.scale_up.job_queued_check_enabled + && local.multi_runner_config_v2.ec2.scale_down.schedule_expression == "rate(10 minutes)" + && local.multi_runner_config_v2.ec2.scale_down.minimum_running_time_in_minutes == 5 + && local.multi_runner_config_v2.ec2.scale_down.idle_config[0].idleCount == 1 + && local.multi_runner_config_v2.ec2.queue.redrive_build_queue.maxReceiveCount == 4 ) - error_message = "The v1 adapter must translate the queue, scale-up, and scale-down blocks." + error_message = "The v2 configuration must preserve the queue, scale-up, and scale-down blocks." } assert { condition = ( - local.multi_runner_config_v1.ec2.runner_config.pool_config[0].size == 1 - && local.multi_runner_config_v1.ec2.runner_config.pool_runner_owner == "cisco-open" - && local.multi_runner_config_v1.ec2.runner_config.job_retry.enable - && local.multi_runner_config_v1.ec2.runner_config.job_retry.delay_in_seconds == 120 - && local.multi_runner_config_v1.ec2.runner_config.job_retry.delay_backoff == 3 - && local.multi_runner_config_v1.ec2.runner_config.job_retry.max_attempts == 2 - && local.multi_runner_config_v1.ec2.runner_config.job_retry.lambda_memory_size == 512 - && local.multi_runner_config_v1.ec2.runner_config.job_retry.lambda_timeout == 45 + local.multi_runner_config_v2.ec2.pool.config[0].size == 1 + && local.multi_runner_config_v2.ec2.pool.runner_owner == "cisco-open" + && local.multi_runner_config_v2.ec2.job_retry.enabled + && local.multi_runner_config_v2.ec2.job_retry.delay_in_seconds == 120 + && local.multi_runner_config_v2.ec2.job_retry.delay_backoff == 3 + && local.multi_runner_config_v2.ec2.job_retry.max_attempts == 2 + && local.multi_runner_config_v2.ec2.job_retry.lambda.memory_size == 512 + && local.multi_runner_config_v2.ec2.job_retry.lambda.reserved_concurrent_executions == 3 + && local.multi_runner_config_v2.ec2.job_retry.lambda.timeout == 45 ) - error_message = "The v1 adapter must translate the pool and job-retry blocks." + error_message = "The v2 configuration must preserve the pool and job-retry blocks." } 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") - && strcontains(local.multi_runner_config_v1.ec2.runner_config.userdata_post_install, "su -l root -c") - && strcontains(local.multi_runner_config_v1.ec2.runner_config.userdata_post_install, "--config /root/.docker") - && length(local.multi_runner_config_v1.ec2.runner_config.runner_log_files) == 4 - && local.multi_runner_config_v1.ec2.runner_config.runner_log_files[3].file_path == "/root/hook.log" - && 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" + local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.pre_install == "caller-pre" + && startswith(local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.post_install, "caller-post\n") + && strcontains(local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.post_install, "su -l root -c") + && strcontains(local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.post_install, "--config /root/.docker") + && local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.debug_logging_enabled + && length(local.multi_runner_config_v2.ec2.compute_provider.ec2.log_files) == 4 + && local.multi_runner_config_v2.ec2.compute_provider.ec2.log_files[3].file_path == "/root/hook.log" + && local.multi_runner_config_v2.ec2.compute_provider.ec2.tags.Environment == "test" + && local.multi_runner_config_v2.ec2.compute_provider.ec2.tags.Lane == "ec2" ) - error_message = "The v1 adapter must retain Forge user-data, logging, and tag overlays." + error_message = "The v2 configuration must retain Forge user-data, logging, and tag overlays." } assert { @@ -389,29 +420,60 @@ run "ec2_v2_input_v1_adapter_plan" { assert { condition = ( startswith( - local.multi_runner_config_v1.ec2.runner_config.runner_hook_job_started, + local.multi_runner_config_v2.ec2.runner.hooks.job_started, "printf '%s' '${base64encode("echo caller-started\nexit 0")}' | base64 --decode | bash\n", ) && startswith( - local.multi_runner_config_v1.ec2.runner_config.runner_hook_job_completed, + local.multi_runner_config_v2.ec2.runner.hooks.job_completed, "printf '%s' '${base64encode("echo caller-completed")}' | base64 --decode | bash\n", ) - && local.multi_runner_config_v1.ec2.runner_config.runner_iam_role_managed_policy_arns[2] == "arn:aws:iam::123456789012:policy/caller" + && local.multi_runner_config_v2.ec2.runner.iam.managed_policy_arns.caller == "arn:aws:iam::123456789012:policy/caller" + && local.multi_runner_config_v2.ec2.runner.iam.managed_policy_arns.forge_ec2_tags == "arn:aws:iam::123456789012:policy/mock" + && local.multi_runner_config_v2.ec2.runner.iam.managed_policy_arns.forge_runner_hooks_ssm_read == "arn:aws:iam::123456789012:policy/mock" ) - error_message = "Caller hooks must be isolated from Forge's required lifecycle hooks, and caller policies must be appended." + error_message = "Caller hooks must be isolated from Forge's required lifecycle hooks, and Forge policies must augment caller policies." } assert { condition = ( - length(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers) == 2 - && tolist(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) - && tolist(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) - && local.multi_runner_config_v1.ec2.matcherConfig.exactMatch - && local.multi_runner_config_v1.ec2.matcherConfig.bidirectionalLabelMatch - && local.multi_runner_config_v1.ec2.matcherConfig.priority == 5 - && local.multi_runner_config_v1.ec2.matcherConfig.enableDynamicLabels - && tolist(local.multi_runner_config_v1.ec2.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) + length(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers) == 2 + && tolist(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) + && tolist(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) + && local.multi_runner_config_v2.ec2.matcherConfig.exactMatch + && local.multi_runner_config_v2.ec2.matcherConfig.bidirectionalLabelMatch + && local.multi_runner_config_v2.ec2.matcherConfig.priority == 5 + && local.multi_runner_config_v2.ec2.matcherConfig.enableDynamicLabels + && tolist(local.multi_runner_config_v2.ec2.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) ) - error_message = "The v1 adapter must preserve matcher configuration." + error_message = "The v2 configuration must preserve matcher configuration." } + + assert { + condition = ( + local.multi_runner_config_v2.ec2.tags.Scope == "entry" + && local.multi_runner_config_v2.ec2.runner.tags.Scope == "runner" + && local.multi_runner_config_v2.ec2.lambda.tags.Scope == "lambda" + && local.multi_runner_config_v2.ec2.queue.tags.Scope == "queue" + && local.multi_runner_config_v2.ec2.scale_up.tags.Scope == "scale-up" + && local.multi_runner_config_v2.ec2.scale_down.tags.Scope == "scale-down" + && local.multi_runner_config_v2.ec2.pool.tags.Scope == "pool" + && local.multi_runner_config_v2.ec2.job_retry.tags.Scope == "job-retry" + && local.multi_runner_config_v2.ec2.ssm.tags.Scope == "ssm" + && local.multi_runner_config_v2.ec2.ssm.parameters.tags.Scope == "ssm-parameters" + && local.multi_runner_config_v2.ec2.ssm.housekeeper.tags.Scope == "ssm-housekeeper" + && local.multi_runner_config_v2.ec2.observability.logs.tags.Scope == "logs" + ) + error_message = "The v2 configuration must preserve every component tag scope." + } + + assert { + condition = ( + local.multi_runner_config_v2.ec2.runner.iam.additional_trust_policy_json == "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + && local.multi_runner_config_v2.ec2.runner.iam.path == "/forge/" + && local.multi_runner_config_v2.ec2.runner.iam.permissions_boundary == "arn:aws:iam::123456789012:policy/boundary" + && local.multi_runner_config_v2.ec2.ssm.kms_key.arn == "arn:aws:kms:eu-west-1:123456789012:key/00000000-0000-0000-0000-000000000000" + ) + error_message = "The v2 configuration must preserve IAM ownership settings and default SSM encryption to Forge's KMS key." + } + } diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index 297fd481..fedd20a4 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -212,18 +212,6 @@ run "platform_ec2_deployment_interface_contract" { "length(runner_config.compute_provider.ec2.ami[*]) == 1", "&& length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0,", "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.\"", - "try(!runner_config.compute_provider.ec2.user_data.debug_logging_enabled, false)", - "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", - "&& length(runner_config.runner.iam.role[*]) == 0,", - "error_message = \"Forge EC2 runner_specs do not support external runner.iam.role or compute_provider.ec2.instance_profile ownership while the upstream v1 adapter is active.\"", - "length(runner_config.runner.tags) == 0", - "&& length(runner_config.observability.logs.tags) == 0", - "error_message = \"Forge EC2 runner_specs only support compute_provider.ec2.tags while the upstream v1 adapter is active; all other v2 per-lane tag maps must remain empty.\"", - "runner_config.runner.iam.additional_trust_policy_json == null", - "&& runner_config.job_retry.lambda.reserved_concurrent_executions == 1", - "&& runner_config.ssm.kms_key == null", - "error_message = \"Forge EC2 runner_specs do not support per-lane IAM trust/path/boundary, non-default job-retry Lambda reserved concurrency, or per-lane SSM KMS keys while the upstream v1 adapter is active.\"", "active_ec2_subnet_ids = toset(flatten([", "legacy_runner_labels = {", "matcher_index, labels in runner_config.matcherConfig.labelMatchers", @@ -243,33 +231,24 @@ 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)", - "multi_runner_config_v1 = {", - "runner_os = runner_config.runner.os", - "runner_metadata_options = local.ec2_compute_provider[key].metadata_options", - "runner_boot_time_in_minutes = runner_config.runner.boot_time_in_minutes", - "enable_organization_runners = runner_config.github.organization_runners", - "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", + "multi_runner_config_v2 = {", + "key => merge(runner_config, {", + "runner = merge(runner_config.runner, {", + "iam = merge(runner_config.runner.iam, {", + "managed_policy_arns = merge(", + "\"forge-config-$${policy_index}\" => policy_arn", + "forge_ec2_tags = aws_iam_policy.ec2_tags.arn", + "forge_runner_hooks_ssm_read = aws_iam_policy.runner_hooks_ssm_read.arn", + "ec2 = local.ec2_compute_provider[key]", + "ssm = merge(runner_config.ssm, {", + "kms_key = runner_config.ssm.kms_key == null ? {", "runner_home = local.effective_runner_home_directories[key]", - "delay_webhook_event = runner_config.queue.delay_webhook_event", - "scale_up_reserved_concurrent_executions = runner_config.scale_up.reserved_concurrent_executions", - "idle_config = runner_config.scale_down.idle_config", - "pool_config = runner_config.pool.config", - "enable = runner_config.job_retry.enabled", - "lambda_memory_size = runner_config.job_retry.lambda.memory_size", - "runner_hook_job_started = local.runner_hook_job_started[key]", - "runner_iam_role_managed_policy_arns = concat(", - "for policy_name in sort(keys(runner_config.runner.iam.managed_policy_arns)) :", - "matcherConfig = runner_config.matcherConfig", - "redrive_build_queue = runner_config.queue.redrive_build_queue", - "multi_runner_config = local.multi_runner_config_v1", + "job_started = local.runner_hook_job_started[key]", + "multi_runner_config = {}", + "multi_runner_config_v2 = local.multi_runner_config_v2", "output \"ec2_runners_arn_map\"", - "for runner_key, runner in module.runners.runners_map : runner_key => runner.role_runner[0].arn", + "for runner_key, runner in module.runners.runners_map_v2 : runner_key => coalesce(", + "try(local.ec2_runner_configs[runner_key].runner.iam.role.arn, null)", "output \"ec2_runners_ami_name_map\"", "data.aws_ami.runner_ami[runner_key].name", "output \"ec2_runners_labels_map\"", @@ -311,7 +290,7 @@ run "platform_ec2_deployment_interface_contract" { condition = ( output.expected_input_variable_count == 4 && output.expected_output_value_count == 6 - && output.expected_interface_literal_count == 257 + && output.expected_interface_literal_count == 236 ) 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 2cddd213..f57ab267 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -12,7 +12,9 @@ run "platform_ec2_deployment_contract" { "module \"ec2_update_runner_tags\"", "module \"runners\"", "source = \"git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=experimental-multi-runner-config-v2-20260805\"", - "multi_runner_config = local.multi_runner_config_v1", + "experimental = {", + "multi_runner_config = {}", + "multi_runner_config_v2 = local.multi_runner_config_v2", "resource \"aws_cloudwatch_log_group\" \"webhook_api_gateway_access\"", "resource \"aws_kms_key\" \"github\"", "resource \"aws_kms_alias\" \"github\"", diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index e0044ac7..dbfd523a 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -272,56 +272,6 @@ 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) : - try(!runner_config.compute_provider.ec2.user_data.debug_logging_enabled, false) - ]) - 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) : - try( - length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 - && length(runner_config.runner.iam.role[*]) == 0, - false, - ) - ]) - error_message = "Forge EC2 runner_specs do not support external runner.iam.role or compute_provider.ec2.instance_profile ownership while the upstream v1 adapter is active." - } - - validation { - condition = alltrue([ - for runner_config in values(var.runner_configs.runner_specs) : - length(runner_config.tags) == 0 - && length(runner_config.runner.tags) == 0 - && length(runner_config.lambda.tags) == 0 - && length(runner_config.queue.tags) == 0 - && length(runner_config.scale_up.tags) == 0 - && length(runner_config.scale_down.tags) == 0 - && length(runner_config.pool.tags) == 0 - && length(runner_config.job_retry.tags) == 0 - && length(runner_config.ssm.tags) == 0 - && length(runner_config.ssm.parameters.tags) == 0 - && length(runner_config.ssm.housekeeper.tags) == 0 - && length(runner_config.observability.logs.tags) == 0 - ]) - error_message = "Forge EC2 runner_specs only support compute_provider.ec2.tags while the upstream v1 adapter is active; all other v2 per-lane tag maps must remain empty." - } - - validation { - condition = alltrue([ - for runner_config in values(var.runner_configs.runner_specs) : - runner_config.runner.iam.additional_trust_policy_json == null - && runner_config.runner.iam.path == null - && runner_config.runner.iam.permissions_boundary == null - && runner_config.job_retry.lambda.reserved_concurrent_executions == 1 - && runner_config.ssm.kms_key == null - ]) - error_message = "Forge EC2 runner_specs do not support per-lane IAM trust/path/boundary, non-default job-retry Lambda reserved concurrency, or per-lane SSM KMS keys while the upstream v1 adapter is active." - } } variable "network_configs" { diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index 70ec004c..2261e3ce 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -21,9 +21,11 @@ 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` uses the full nested v2 shape; every entry must configure `runner`, `matcherConfig`, and `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 - or change its Terraform module/resource addresses. +- `ec2_deployment` enriches that public shape with Forge-managed settings and + passes it directly to upstream's experimental `multi_runner_config_v2`. +- The v2 runner graph changes internal Terraform addresses and has no upstream + `moved` blocks. Use this ref for new state only until a state migration and + exact-ref Lambda artifacts have been validated for existing deployments. - Changing GitHub App or runner-group settings can affect job routing immediately. ## Deployment version inventory @@ -67,7 +69,7 @@ tag with the expected ref in `release_versions.yml`. | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | +| [aws](#provider\_aws) | 6.59.0 | | [null](#provider\_null) | 3.3.0 | | [random](#provider\_random) | 3.9.0 | | [time](#provider\_time) | 0.14.0 | @@ -119,7 +121,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. 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 : Provider-neutral OS, architecture, labels, registration,
hooks, capacity, and IAM-policy configuration.
- github : Organization-versus-repository runner registration.
- queue : Webhook delay, retention, Lambda batching, and DLQ
redrive configuration.
- scale\_up : Scale-up concurrency and queued-job checks.
- scale\_down : Scale-down schedule, minimum runtime, and idle runners.
- pool : Scheduled warm-pool sizes and runner owner.
- job\_retry : Retry timing, attempts, and Lambda sizing.
- matcherConfig : Static and dynamic GitHub label matching.
- compute\_provider: Nested v2-compatible EC2 provider configuration.
- tags/lambda/ssm/observability: Included for v2 contract compatibility.
Tag scopes and per-lane SSM KMS settings that cannot be
represented by v1 must retain their defaults.

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.

The v7.10.1 compatibility adapter also requires external runner IAM ownership,
per-lane IAM trust/path/boundary settings, and non-default job-retry Lambda
reserved concurrency to remain unset. These constraints prevent accepted v2
settings from being silently discarded by the stable v1 module. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = number
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
tags = optional(map(string), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
}), {})

scale_up = optional(object({
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
tags = optional(map(string), {})
}), {})

scale_down = optional(object({
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
tags = optional(map(string), {})
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
}), {})

pool = optional(object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
tags = optional(map(string), {})
kms_key = optional(object({
arn = string
}), null)
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
tags = optional(map(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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | EC2 deployment configuration for GitHub Actions runners. The public runner
shape follows the nested experimental multi\_runner\_config\_v2 EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 : Provider-neutral OS, architecture, labels, registration,
hooks, capacity, and IAM-policy configuration.
- github : Organization-versus-repository runner registration.
- queue : Webhook delay, retention, Lambda batching, and DLQ
redrive configuration.
- scale\_up : Scale-up concurrency and queued-job checks.
- scale\_down : Scale-down schedule, minimum runtime, and idle runners.
- pool : Scheduled warm-pool sizes and runner owner.
- job\_retry : Retry timing, attempts, and Lambda sizing.
- matcherConfig : Static and dynamic GitHub label matching.
- compute\_provider: Nested v2 EC2 provider configuration.
- tags/lambda/ssm/observability: Component-level tags, SSM configuration,
and CloudWatch Logs 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: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = number
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
tags = optional(map(string), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
}), {})

scale_up = optional(object({
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
tags = optional(map(string), {})
}), {})

scale_down = optional(object({
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
tags = optional(map(string), {})
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
}), {})

pool = optional(object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
tags = optional(map(string), {})
kms_key = optional(object({
arn = string
}), null)
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
tags = optional(map(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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
})
| 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 6dca56d8..9eec63d2 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -298,18 +298,6 @@ run "platform_forge_runners_interface_contract" { "length(runner_config.compute_provider.ec2.ami[*]) == 1", "&& length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0,", "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.\"", - "try(!runner_config.compute_provider.ec2.user_data.debug_logging_enabled, false)", - "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", - "&& length(runner_config.runner.iam.role[*]) == 0,", - "error_message = \"Forge EC2 runner_specs do not support external runner.iam.role or compute_provider.ec2.instance_profile ownership while the upstream v1 adapter is active.\"", - "length(runner_config.runner.tags) == 0", - "&& length(runner_config.observability.logs.tags) == 0", - "error_message = \"Forge EC2 runner_specs only support compute_provider.ec2.tags while the upstream v1 adapter is active; all other v2 per-lane tag maps must remain empty.\"", - "runner_config.runner.iam.additional_trust_policy_json == null", - "&& runner_config.job_retry.lambda.reserved_concurrent_executions == 1", - "&& runner_config.ssm.kms_key == null", - "error_message = \"Forge EC2 runner_specs do not support per-lane IAM trust/path/boundary, non-default job-retry Lambda reserved concurrency, or per-lane SSM KMS keys while the upstream v1 adapter is active.\"", "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.", @@ -320,11 +308,14 @@ run "platform_forge_runners_interface_contract" { "- queue : Webhook delay, retention, Lambda batching, and DLQ", "- job_retry : Retry timing, attempts, and Lambda sizing.", "- matcherConfig : Static and dynamic GitHub label matching.", - "- compute_provider: Nested v2-compatible EC2 provider configuration.", - "- tags/lambda/ssm/observability: Included for v2 contract compatibility.", + "shape follows the nested experimental multi_runner_config_v2 EC2 contract and", + "is passed directly to the upstream provider-oriented runner stack.", + "- compute_provider: Nested v2 EC2 provider configuration.", + "- tags/lambda/ssm/observability: Component-level tags, SSM configuration,", "compute_provider.ec2 fields:", "- ami : Upstream-compatible EC2 AMI configuration.", - "The v7.10.1 compatibility adapter also requires external runner IAM ownership,", + "- instance_profile: Optional externally managed instance profile. It requires", + "supplies Forge's EC2-tag and hook-SSM permissions.", "variable \"github_webhook_relay\"", "Configuration for the (optional) webhook relay source module.", "If enabled=true we provision the API Gateway + source EventBridge forwarding rule.", @@ -411,7 +402,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 == 352 + && output.expected_interface_literal_count == 343 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index c765dd8b..1102b2d5 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -270,61 +270,10 @@ 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) : - try(!runner_config.compute_provider.ec2.user_data.debug_logging_enabled, false) - ]) - 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) : - try( - length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 - && length(runner_config.runner.iam.role[*]) == 0, - false, - ) - ]) - error_message = "Forge EC2 runner_specs do not support external runner.iam.role or compute_provider.ec2.instance_profile ownership while the upstream v1 adapter is active." - } - - validation { - condition = alltrue([ - for runner_config in values(var.ec2_deployment_specs.runner_specs) : - length(runner_config.tags) == 0 - && length(runner_config.runner.tags) == 0 - && length(runner_config.lambda.tags) == 0 - && length(runner_config.queue.tags) == 0 - && length(runner_config.scale_up.tags) == 0 - && length(runner_config.scale_down.tags) == 0 - && length(runner_config.pool.tags) == 0 - && length(runner_config.job_retry.tags) == 0 - && length(runner_config.ssm.tags) == 0 - && length(runner_config.ssm.parameters.tags) == 0 - && length(runner_config.ssm.housekeeper.tags) == 0 - && length(runner_config.observability.logs.tags) == 0 - ]) - error_message = "Forge EC2 runner_specs only support compute_provider.ec2.tags while the upstream v1 adapter is active; all other v2 per-lane tag maps must remain empty." - } - - validation { - condition = alltrue([ - for runner_config in values(var.ec2_deployment_specs.runner_specs) : - runner_config.runner.iam.additional_trust_policy_json == null - && runner_config.runner.iam.path == null - && runner_config.runner.iam.permissions_boundary == null - && runner_config.job_retry.lambda.reserved_concurrent_executions == 1 - && runner_config.ssm.kms_key == null - ]) - error_message = "Forge EC2 runner_specs do not support per-lane IAM trust/path/boundary, non-default job-retry Lambda reserved concurrency, or per-lane SSM KMS keys while the upstream v1 adapter is active." - } - description = <<-EOT 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. + shape follows the nested experimental multi_runner_config_v2 EC2 contract and + is passed directly to the upstream provider-oriented runner stack. Top-level fields: - lambda_subnet_ids: Subnets where runner-related lambdas execute. @@ -344,10 +293,9 @@ variable "ec2_deployment_specs" { - pool : Scheduled warm-pool sizes and runner owner. - job_retry : Retry timing, attempts, and Lambda sizing. - matcherConfig : Static and dynamic GitHub label matching. - - compute_provider: Nested v2-compatible EC2 provider configuration. - - tags/lambda/ssm/observability: Included for v2 contract compatibility. - Tag scopes and per-lane SSM KMS settings that cannot be - represented by v1 must retain their defaults. + - compute_provider: Nested v2 EC2 provider configuration. + - tags/lambda/ssm/observability: Component-level tags, SSM configuration, + and CloudWatch Logs configuration. compute_provider.ec2 fields: - ami : Upstream-compatible EC2 AMI configuration. @@ -356,18 +304,13 @@ 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. - - instance_profile: Upstream contract field reserved for future Forge support. + - instance_profile: Optional externally managed instance profile. It requires + an externally managed runner IAM role whose owner also + supplies Forge's EC2-tag and hook-SSM permissions. - log_files/tags : EC2 logging and resource tags. - - The v7.10.1 compatibility adapter also requires external runner IAM ownership, - per-lane IAM trust/path/boundary settings, and non-default job-retry Lambda - reserved concurrency to remain unset. These constraints prevent accepted v2 - settings from being silently discarded by the stable v1 module. EOT } From bf24138d77d84250baa94c814fd3d1bc4a87437e Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 11:45:19 +0200 Subject: [PATCH 3/8] feat(runners): align Forge with experimental multi-runner contract --- .../main/tenants/acme/runner_settings.hcl | 31 +- .../platform/tenant/runner_settings.hcl | 31 +- modules/platform/ec2_deployment/README.md | 21 +- .../ec2_update_runner_ssm_ami.tf | 15 +- modules/platform/ec2_deployment/main.tf | 37 +- .../ec2_deployment/runner_configs_v2.tf | 101 ++++- .../ec2_deployment/tests/behavior.tftest.hcl | 427 +++++++++++++----- .../tests/interface_contract.tftest.hcl | 106 +++-- .../tests/source_inventory.tftest.hcl | 11 +- modules/platform/ec2_deployment/variables.tf | 186 +++++--- modules/platform/forge_runners/README.md | 9 +- .../tests/interface_contract.tftest.hcl | 93 ++-- .../tests/source_inventory.tftest.hcl | 2 + modules/platform/forge_runners/variables.tf | 202 ++++++--- 14 files changed, 855 insertions(+), 417 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 50815a3c..87b05553 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 @@ -81,28 +81,31 @@ locals { github = { organization_runners = true } + lambda = { + scale_up = { + job_queued_check_enabled = false + event_source_mapping = { + batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + } + } + scale_down = { + minimum_running_time_in_minutes = 30 + } + pool = { + config = spec.pool_config + runner_owner = local.config.gh_config.ghes_org + } + } queue = { delay_webhook_event = 0 job_queue_retention_in_seconds = 172800 - event_source_mapping = { - batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) - } + visibility_timeout_seconds = 180 redrive_build_queue = { enabled = try(spec.redrive_build_queue.enabled, true) maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) } } - scale_up = { - job_queued_check_enabled = false - } - scale_down = { - minimum_running_time_in_minutes = 30 - } - pool = { - config = spec.pool_config - runner_owner = local.config.gh_config.ghes_org - } compute_provider = { ec2 = { metadata_options = { diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index 50815a3c..87b05553 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -81,28 +81,31 @@ locals { github = { organization_runners = true } + lambda = { + scale_up = { + job_queued_check_enabled = false + event_source_mapping = { + batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + } + } + scale_down = { + minimum_running_time_in_minutes = 30 + } + pool = { + config = spec.pool_config + runner_owner = local.config.gh_config.ghes_org + } + } queue = { delay_webhook_event = 0 job_queue_retention_in_seconds = 172800 - event_source_mapping = { - batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) - } + visibility_timeout_seconds = 180 redrive_build_queue = { enabled = try(spec.redrive_build_queue.enabled, true) maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) } } - scale_up = { - job_queued_check_enabled = false - } - scale_down = { - minimum_running_time_in_minutes = 30 - } - pool = { - config = spec.pool_config - runner_owner = local.config.gh_config.ghes_org - } compute_provider = { ec2 = { metadata_options = { diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index 11f148c0..e2bafa19 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -5,12 +5,14 @@ This module deploys Forge EC2 runner pools through the upstream ## Why This Module Exists -The v2 input lets each runner lane configure the common runner, GitHub, queue, -scaling, pool, retry, SSM, observability, matcher, and EC2 provider blocks while -sharing Forge's webhook and runner control plane. Forge enriches each lane with -its required hooks, IAM policies, bootstrap content, logging, tags, and KMS key, -then passes it to upstream's experimental `multi_runner_config_v2`. EC2 supports -custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. +The experimental input lets each runner lane configure runner, GitHub, queue, +Lambda scaling and pools, retry, SSM, observability, matcher, and EC2 provider +blocks while sharing Forge's webhook and runner control plane. Forge enriches +each lane with its required hooks, IAM policies, bootstrap content, logging, and +tags, then passes the canonical map through `experimental.multi_runner_config`. +Global experimental settings own the GitHub App, Lambda artifacts and network, +SSM KMS key, logging defaults, and EC2 network defaults. EC2 supports custom +AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## What It Manages @@ -23,7 +25,10 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. - This is a breaking input migration: every `runner_specs` entry uses the full nested v2 shape and must contain `runner`, `matcherConfig`, and - `compute_provider.ec2`. The legacy flat EC2 shape is not accepted. + `compute_provider.ec2`. Scale-up, scale-down, and pool settings live below + `lambda`; event-source batching lives below `lambda.scale_up`; queue + visibility is configured by `queue.visibility_timeout_seconds`. The legacy + flat EC2 shape is not accepted. - Forge still owns scheduled AMI refresh, so EC2 lanes require a non-null, module-managed `ami` block and cannot select `ami.id_ssm_parameter`. The refresh uses the same default AMI name filter as the upstream EC2 provider for @@ -96,7 +101,7 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. | ---- | ----------- | ---- | ------- | :------: | | [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
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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = number
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
tags = optional(map(string), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
}), {})

scale_up = optional(object({
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
tags = optional(map(string), {})
}), {})

scale_down = optional(object({
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
tags = optional(map(string), {})
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
}), {})

pool = optional(object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
tags = optional(map(string), {})
kms_key = optional(object({
arn = string
}), null)
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
tags = optional(map(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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
})
| n/a | yes | +| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = 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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = number
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
})
| 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 1fc91527..e24ff1de 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf +++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf @@ -3,9 +3,20 @@ data "aws_caller_identity" "current" {} data "aws_partition" "current" {} locals { + runner_ssm_paths = { + for key, runner_config in local.ec2_runner_configs : + key => { + root = "${trimsuffix(coalesce(runner_config.ssm.paths.root, "/github-action-runners/${var.runner_configs.prefix}"), "/")}/${key}" + config = coalesce( + runner_config.ssm.paths.config, + "runners/config", + ) + } + } + runner_ami_ssm_parameter_names = { - for key in keys(local.ec2_runner_configs) : - key => "/github-action-runners/${var.runner_configs.prefix}/${key}/runners/config/ami_id" + for key, paths in local.runner_ssm_paths : + key => "${paths.root}/${paths.config}/ami_id" } runner_ami_ssm_parameter_arns = { diff --git a/modules/platform/ec2_deployment/main.tf b/modules/platform/ec2_deployment/main.tf index f797e962..7c653508 100644 --- a/modules/platform/ec2_deployment/main.tf +++ b/modules/platform/ec2_deployment/main.tf @@ -130,43 +130,10 @@ module "runners" { aws_region = var.aws_region - vpc_id = var.network_configs.vpc_id - subnet_ids = var.network_configs.subnet_ids - lambda_subnet_ids = var.network_configs.lambda_subnet_ids - lambda_security_group_ids = [aws_security_group.gh_runner_lambda_egress.id] - kms_key_arn = aws_kms_key.github.arn - ghes_url = var.runner_configs.ghes_url - prefix = var.runner_configs.prefix - - # For authenticating against the GitHub App we created. - github_app = var.runner_configs.github_app - - eventbridge = { - enable = true - } - - lambda_tags = local.terraform_aws_github_runner_tags - tags = local.terraform_aws_github_runner_tags - parameter_store_tags = local.terraform_aws_github_runner_tags - - # Verbose logging. - log_level = var.runner_configs.log_level - - # Retention period for the logs in days. - logging_retention_in_days = var.runner_configs.logging_retention_in_days - - webhook_lambda_zip = "${data.external.download_lambdas.result.path}/webhook.zip" - webhook_lambda_apigateway_access_log_settings = { - destination_arn = aws_cloudwatch_log_group.webhook_api_gateway_access.arn - format = local.webhook_api_gateway_access_log_format - } - 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" + prefix = var.runner_configs.prefix multi_runner_config = {} - experimental = { - multi_runner_config = local.multi_runner_config - } + experimental = local.experimental_config depends_on = [ data.external.download_lambdas, diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf index 7b397cb0..6028e6f4 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -3,16 +3,24 @@ locals { effective_runner_users = { for key, runner_config in local.ec2_runner_configs : - key => runner_config.runner.run_as_root ? "root" : runner_config.runner.run_as + key => coalesce(runner_config.runner.run_as_root, false) ? "root" : coalesce(runner_config.runner.run_as, "ec2-user") } effective_runner_home_directories = { for key, runner_config in local.ec2_runner_configs : key => runner_config.runner.os == "windows" ? "C:/Users/Administrator" : ( - runner_config.runner.run_as_root ? (runner_config.runner.os == "osx" ? "/var/root" : "/root") : "/home/${runner_config.runner.run_as}" + coalesce(runner_config.runner.run_as_root, false) ? (runner_config.runner.os == "osx" ? "/var/root" : "/root") : "/home/${coalesce(runner_config.runner.run_as, "ec2-user")}" ) } + effective_runner_hooks = { + for key, runner_config in local.ec2_runner_configs : + key => { + job_started = runner_config.runner.hooks.job_started == null ? "" : runner_config.runner.hooks.job_started + job_completed = runner_config.runner.hooks.job_completed == null ? "" : runner_config.runner.hooks.job_completed + } + } + forge_runner_hook_job_started = { for key, runner_config in local.ec2_runner_configs : key => templatefile( @@ -40,20 +48,20 @@ locals { # configuration to avoid state churn for migrated lanes. runner_hook_job_started = { for key, runner_config in local.ec2_runner_configs : - key => runner_config.runner.hooks.job_started == "" ? local.forge_runner_hook_job_started[key] : join("\n", [ + key => local.effective_runner_hooks[key].job_started == "" ? local.forge_runner_hook_job_started[key] : join("\n", [ runner_config.runner.os == "windows" ? - "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${textencodebase64(runner_config.runner.hooks.job_started, "UTF-16LE")}" : - "printf '%s' '${base64encode(runner_config.runner.hooks.job_started)}' | base64 --decode | bash", + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${textencodebase64(local.effective_runner_hooks[key].job_started, "UTF-16LE")}" : + "printf '%s' '${base64encode(local.effective_runner_hooks[key].job_started)}' | base64 --decode | bash", local.forge_runner_hook_job_started[key], ]) } runner_hook_job_completed = { for key, runner_config in local.ec2_runner_configs : - key => runner_config.runner.hooks.job_completed == "" ? local.forge_runner_hook_job_completed[key] : join("\n", [ + key => local.effective_runner_hooks[key].job_completed == "" ? local.forge_runner_hook_job_completed[key] : join("\n", [ runner_config.runner.os == "windows" ? - "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${textencodebase64(runner_config.runner.hooks.job_completed, "UTF-16LE")}" : - "printf '%s' '${base64encode(runner_config.runner.hooks.job_completed)}' | base64 --decode | bash", + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${textencodebase64(local.effective_runner_hooks[key].job_completed, "UTF-16LE")}" : + "printf '%s' '${base64encode(local.effective_runner_hooks[key].job_completed)}' | base64 --decode | bash", local.forge_runner_hook_job_completed[key], ]) } @@ -84,7 +92,7 @@ locals { for key, runner_config in local.ec2_runner_configs : key => concat( try(runner_config.matcherConfig.labelMatchers[0], []), - runner_config.runner.extra_labels, + coalesce(runner_config.runner.extra_labels, []), ) } @@ -190,7 +198,7 @@ locals { } iam = merge(runner_config.runner.iam, { managed_policy_arns = merge( - runner_config.runner.iam.managed_policy_arns, + coalesce(runner_config.runner.iam.managed_policy_arns, {}), runner_config.runner.iam.role == null ? merge( { for policy_index, policy_arn in var.runner_configs.runner_iam_role_managed_policy_arns : @@ -207,11 +215,74 @@ locals { compute_provider = { ec2 = local.ec2_compute_provider[key] } - ssm = merge(runner_config.ssm, { - kms_key = runner_config.ssm.kms_key == null ? { - arn = aws_kms_key.github.arn - } : runner_config.ssm.kms_key - }) }) } + + experimental_config = { + tags = local.terraform_aws_github_runner_tags + + github = { + app = var.runner_configs.github_app + } + + enterprise_server = { + url = try(trimspace(var.runner_configs.ghes_url), "") == "" ? null : var.runner_configs.ghes_url + } + + webhook = { + eventbridge = { + enable = true + } + } + + lambda = { + scale = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/runners.zip" + } + } + subnet_ids = var.network_configs.lambda_subnet_ids + security_group_ids = [aws_security_group.gh_runner_lambda_egress.id] + tags = local.terraform_aws_github_runner_tags + webhook = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/webhook.zip" + } + api_gateway_access_log_settings = { + destination_arn = aws_cloudwatch_log_group.webhook_api_gateway_access.arn + format = local.webhook_api_gateway_access_log_format + } + } + } + + ssm = { + kms_key_id = aws_kms_key.github.arn + parameters = { + tags = local.terraform_aws_github_runner_tags + } + } + + observability = { + logs = { + level = lower(var.runner_configs.log_level) + retention_in_days = tonumber(var.runner_configs.logging_retention_in_days) + } + } + + compute_provider = { + ec2 = { + vpc_id = var.network_configs.vpc_id + subnet_ids = var.network_configs.subnet_ids + runner_binaries = { + syncer = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/runner-binaries-syncer.zip" + } + } + } + } + } + + multi_runner_config = local.multi_runner_config + } } diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index d53851ca..d0755e30 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -159,46 +159,63 @@ variables { organization_runners = true } lambda = { - tags = { Scope = "lambda" } + runtime = "nodejs22.x" + architecture = "x86_64" + subnet_ids = ["subnet-lambda-override"] + security_group_ids = ["sg-lambda"] + tags = { Scope = "lambda" } + role = { + path = "/lambda/" + permissions_boundary = "arn:aws:iam::123456789012:policy/lambda-boundary" + } + scale_up = { + memory_size = 768 + timeout = 40 + reserved_concurrent_executions = 2 + job_queued_check_enabled = false + event_source_mapping = { + batch_size = 5 + maximum_batching_window_in_seconds = 1 + } + tags = { Scope = "scale-up" } + } + scale_down = { + memory_size = 1024 + timeout = 90 + schedule_expression = "rate(10 minutes)" + minimum_running_time_in_minutes = 5 + idle_config = [{ + cron = "* * * * *" + timeZone = "Europe/Warsaw" + idleCount = 1 + evictionStrategy = "newest_first" + }] + tags = { Scope = "scale-down" } + } + pool = { + memory_size = 512 + timeout = 75 + reserved_concurrent_executions = 2 + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + schedule_expression_timezone = "Europe/Warsaw" + size = 1 + }] + include_busy_runners = false + runner_owner = "cisco-open" + tags = { Scope = "pool" } + } } queue = { delay_webhook_event = 7 job_queue_retention_in_seconds = 90000 - event_source_mapping = { - batch_size = 5 - maximum_batching_window_in_seconds = 1 - } + visibility_timeout_seconds = 240 redrive_build_queue = { enabled = true maxReceiveCount = 4 } tags = { Scope = "queue" } } - scale_up = { - reserved_concurrent_executions = 2 - job_queued_check_enabled = false - tags = { Scope = "scale-up" } - } - scale_down = { - schedule_expression = "rate(10 minutes)" - minimum_running_time_in_minutes = 5 - idle_config = [{ - cron = "* * * * *" - timeZone = "Europe/Warsaw" - idleCount = 1 - evictionStrategy = "newest_first" - }] - tags = { Scope = "scale-down" } - } - pool = { - config = [{ - schedule_expression = "cron(0 8 * * ? *)" - schedule_expression_timezone = "Europe/Warsaw" - size = 1 - }] - runner_owner = "cisco-open" - tags = { Scope = "pool" } - } job_retry = { enabled = true delay_in_seconds = 120 @@ -213,16 +230,49 @@ variables { } ssm = { tags = { Scope = "ssm" } + paths = { + root = "/custom-root" + tokens = "custom-tokens" + config = "custom-config" + } parameters = { tags = { Scope = "ssm-parameters" } } housekeeper = { - tags = { Scope = "ssm-housekeeper" } + schedule_expression = "rate(12 hours)" + state = "DISABLED" + tags = { Scope = "ssm-housekeeper" } + lambda = { + memory_size = 384 + timeout = 70 + } + config = { + tokenPath = "custom-token-path" + minimumDaysOld = 2 + dryRun = true + } } } observability = { logs = { - tags = { Scope = "logs" } + level = "debug" + retention_in_days = 14 + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/22222222-2222-2222-2222-222222222222" + class = "INFREQUENT_ACCESS" + tags = { Scope = "logs" } + } + tracing = { + mode = "Active" + capture_http_requests = false + capture_error = false + } + metrics = { + enable = false + namespace = "Forge/Test" + metric = { + enable_github_app_rate_limit = false + enable_job_retry = true + } } } compute_provider = { @@ -250,13 +300,28 @@ variables { 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"] + 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"] + managed_security_group_enabled = false + egress_rules = [{ + cidr_blocks = ["10.0.0.0/8"] + ipv6_cidr_blocks = [] + prefix_list_ids = [] + from_port = 443 + protocol = "tcp" + security_groups = [] + self = false + to_port = 443 + description = "HTTPS" + }] + instance_profile_path = "/runner/" + key_name = "forge-test" + associate_public_ipv4_address = false scale_errors = ["InsufficientInstanceCapacity"] ssm_enabled = true subnet_ids = ["subnet-override"] @@ -291,6 +356,25 @@ variables { } } } + default_path = { + runner = { + os = "linux" + architecture = "x64" + maximum_count = 1 + } + compute_provider = { + ec2 = { + ami = {} + instance_types = ["m7i.large"] + binaries_syncer = { + enabled = false + } + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "default-path"]] + } + } } } } @@ -306,104 +390,118 @@ run "ec2_v2_input_plan" { } assert { - condition = toset(keys(local.ec2_runner_configs)) == toset(["ec2"]) + condition = toset(keys(local.ec2_runner_configs)) == toset(["default_path", "ec2"]) error_message = "EC2 provider filtering must retain every EC2 lane." } assert { - condition = toset(keys(local.multi_runner_config_v2)) == toset(["ec2"]) + condition = toset(keys(local.multi_runner_config)) == toset(["default_path", "ec2"]) error_message = "The upstream v2 configuration must preserve every EC2 lane key." } assert { - condition = local.active_ec2_subnet_ids == toset(["subnet-override"]) + condition = local.active_ec2_subnet_ids == toset(["subnet-default", "subnet-override"]) error_message = "EC2 effective subnet resolution must preserve per-lane overrides." } assert { condition = ( - 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.ami.kms_key.arn == "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" - && local.multi_runner_config_v2.ec2.compute_provider.ec2.ebs_optimized + tolist(local.multi_runner_config.ec2.compute_provider.ec2.ami.filter.name) == tolist(["forge-*"]) + && local.multi_runner_config.ec2.compute_provider.ec2.ami.id_ssm_parameter == null + && local.multi_runner_config.ec2.compute_provider.ec2.ami.kms_key.arn == "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + && local.multi_runner_config.ec2.compute_provider.ec2.ebs_optimized ) error_message = "The v2 configuration must preserve nested EC2 AMI and fleet configuration." } assert { condition = ( - local.multi_runner_config_v2.ec2.compute_provider.ec2.metadata_options.http_tokens == "optional" - && local.multi_runner_config_v2.ec2.compute_provider.ec2.metadata_options.http_put_response_hop_limit == 2 - && local.multi_runner_config_v2.ec2.compute_provider.ec2.cloudwatch_agent.enabled - && local.multi_runner_config_v2.ec2.compute_provider.ec2.cloudwatch_agent.config == "{\"agent\":{}}" - && !local.multi_runner_config_v2.ec2.compute_provider.ec2.binaries_syncer.enabled - && local.multi_runner_config_v2.ec2.compute_provider.ec2.detailed_monitoring_enabled - && local.multi_runner_config_v2.ec2.compute_provider.ec2.ssm_enabled - && tolist(local.multi_runner_config_v2.ec2.compute_provider.ec2.additional_security_group_ids) == tolist(["sg-runner"]) + local.multi_runner_config.ec2.compute_provider.ec2.metadata_options.http_tokens == "optional" + && local.multi_runner_config.ec2.compute_provider.ec2.metadata_options.http_put_response_hop_limit == 2 + && local.multi_runner_config.ec2.compute_provider.ec2.cloudwatch_agent.enabled + && local.multi_runner_config.ec2.compute_provider.ec2.cloudwatch_agent.config == "{\"agent\":{}}" + && !local.multi_runner_config.ec2.compute_provider.ec2.binaries_syncer.enabled + && local.multi_runner_config.ec2.compute_provider.ec2.detailed_monitoring_enabled + && local.multi_runner_config.ec2.compute_provider.ec2.ssm_enabled + && tolist(local.multi_runner_config.ec2.compute_provider.ec2.additional_security_group_ids) == tolist(["sg-runner"]) + && !local.multi_runner_config.ec2.compute_provider.ec2.managed_security_group_enabled + && local.multi_runner_config.ec2.compute_provider.ec2.egress_rules[0].description == "HTTPS" + && local.multi_runner_config.ec2.compute_provider.ec2.instance_profile_path == "/runner/" + && local.multi_runner_config.ec2.compute_provider.ec2.key_name == "forge-test" + && !local.multi_runner_config.ec2.compute_provider.ec2.associate_public_ipv4_address ) error_message = "The v2 configuration must preserve nested EC2 bootstrap, metadata, and networking settings." } assert { condition = ( - local.multi_runner_config_v2.ec2.runner.boot_time_in_minutes == 7 - && local.multi_runner_config_v2.ec2.runner.disable_default_labels - && tolist(local.multi_runner_config_v2.ec2.runner.extra_labels) == tolist(["caller-extra"]) - && local.multi_runner_config_v2.ec2.runner.group_name == "Forge" - && local.multi_runner_config_v2.ec2.runner.name_prefix == "forge-" - && local.multi_runner_config_v2.ec2.runner.run_as_root - && local.multi_runner_config_v2.ec2.runner.run_as == "ec2-user" - && local.multi_runner_config_v2.ec2.runner.maximum_count == 2 - && local.multi_runner_config_v2.ec2.runner.ephemeral - && !local.multi_runner_config_v2.ec2.runner.jit_config_enabled - && local.multi_runner_config_v2.ec2.runner.auto_update_disabled - && local.multi_runner_config_v2.ec2.github.organization_runners + local.multi_runner_config.ec2.runner.boot_time_in_minutes == 7 + && local.multi_runner_config.ec2.runner.disable_default_labels + && tolist(local.multi_runner_config.ec2.runner.extra_labels) == tolist(["caller-extra"]) + && local.multi_runner_config.ec2.runner.group_name == "Forge" + && local.multi_runner_config.ec2.runner.name_prefix == "forge-" + && local.multi_runner_config.ec2.runner.run_as_root + && local.multi_runner_config.ec2.runner.run_as == "ec2-user" + && local.multi_runner_config.ec2.runner.maximum_count == 2 + && local.multi_runner_config.ec2.runner.ephemeral + && !local.multi_runner_config.ec2.runner.jit_config_enabled + && local.multi_runner_config.ec2.runner.auto_update_disabled + && local.multi_runner_config.ec2.github.organization_runners ) error_message = "The v2 configuration must preserve the complete nested runner and GitHub blocks." } assert { condition = ( - local.multi_runner_config_v2.ec2.queue.delay_webhook_event == 7 - && local.multi_runner_config_v2.ec2.queue.job_queue_retention_in_seconds == 90000 - && local.multi_runner_config_v2.ec2.queue.event_source_mapping.batch_size == 5 - && local.multi_runner_config_v2.ec2.queue.event_source_mapping.maximum_batching_window_in_seconds == 1 - && local.multi_runner_config_v2.ec2.scale_up.reserved_concurrent_executions == 2 - && !local.multi_runner_config_v2.ec2.scale_up.job_queued_check_enabled - && local.multi_runner_config_v2.ec2.scale_down.schedule_expression == "rate(10 minutes)" - && local.multi_runner_config_v2.ec2.scale_down.minimum_running_time_in_minutes == 5 - && local.multi_runner_config_v2.ec2.scale_down.idle_config[0].idleCount == 1 - && local.multi_runner_config_v2.ec2.queue.redrive_build_queue.maxReceiveCount == 4 + local.multi_runner_config.ec2.queue.delay_webhook_event == 7 + && local.multi_runner_config.ec2.queue.job_queue_retention_in_seconds == 90000 + && local.multi_runner_config.ec2.queue.visibility_timeout_seconds == 240 + && local.multi_runner_config.ec2.lambda.scale_up.event_source_mapping.batch_size == 5 + && local.multi_runner_config.ec2.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 1 + && local.multi_runner_config.ec2.lambda.scale_up.memory_size == 768 + && local.multi_runner_config.ec2.lambda.scale_up.timeout == 40 + && local.multi_runner_config.ec2.lambda.scale_up.reserved_concurrent_executions == 2 + && !local.multi_runner_config.ec2.lambda.scale_up.job_queued_check_enabled + && local.multi_runner_config.ec2.lambda.scale_down.memory_size == 1024 + && local.multi_runner_config.ec2.lambda.scale_down.timeout == 90 + && local.multi_runner_config.ec2.lambda.scale_down.schedule_expression == "rate(10 minutes)" + && local.multi_runner_config.ec2.lambda.scale_down.minimum_running_time_in_minutes == 5 + && local.multi_runner_config.ec2.lambda.scale_down.idle_config[0].idleCount == 1 + && local.multi_runner_config.ec2.queue.redrive_build_queue.maxReceiveCount == 4 ) error_message = "The v2 configuration must preserve the queue, scale-up, and scale-down blocks." } assert { condition = ( - local.multi_runner_config_v2.ec2.pool.config[0].size == 1 - && local.multi_runner_config_v2.ec2.pool.runner_owner == "cisco-open" - && local.multi_runner_config_v2.ec2.job_retry.enabled - && local.multi_runner_config_v2.ec2.job_retry.delay_in_seconds == 120 - && local.multi_runner_config_v2.ec2.job_retry.delay_backoff == 3 - && local.multi_runner_config_v2.ec2.job_retry.max_attempts == 2 - && local.multi_runner_config_v2.ec2.job_retry.lambda.memory_size == 512 - && local.multi_runner_config_v2.ec2.job_retry.lambda.reserved_concurrent_executions == 3 - && local.multi_runner_config_v2.ec2.job_retry.lambda.timeout == 45 + local.multi_runner_config.ec2.lambda.pool.config[0].size == 1 + && local.multi_runner_config.ec2.lambda.pool.memory_size == 512 + && local.multi_runner_config.ec2.lambda.pool.timeout == 75 + && local.multi_runner_config.ec2.lambda.pool.reserved_concurrent_executions == 2 + && !local.multi_runner_config.ec2.lambda.pool.include_busy_runners + && local.multi_runner_config.ec2.lambda.pool.runner_owner == "cisco-open" + && local.multi_runner_config.ec2.job_retry.enabled + && local.multi_runner_config.ec2.job_retry.delay_in_seconds == 120 + && local.multi_runner_config.ec2.job_retry.delay_backoff == 3 + && local.multi_runner_config.ec2.job_retry.max_attempts == 2 + && local.multi_runner_config.ec2.job_retry.lambda.memory_size == 512 + && local.multi_runner_config.ec2.job_retry.lambda.reserved_concurrent_executions == 3 + && local.multi_runner_config.ec2.job_retry.lambda.timeout == 45 ) error_message = "The v2 configuration must preserve the pool and job-retry blocks." } assert { condition = ( - local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.pre_install == "caller-pre" - && startswith(local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.post_install, "caller-post\n") - && strcontains(local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.post_install, "su -l root -c") - && strcontains(local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.post_install, "--config /root/.docker") - && local.multi_runner_config_v2.ec2.compute_provider.ec2.user_data.debug_logging_enabled - && length(local.multi_runner_config_v2.ec2.compute_provider.ec2.log_files) == 4 - && local.multi_runner_config_v2.ec2.compute_provider.ec2.log_files[3].file_path == "/root/hook.log" - && local.multi_runner_config_v2.ec2.compute_provider.ec2.tags.Environment == "test" - && local.multi_runner_config_v2.ec2.compute_provider.ec2.tags.Lane == "ec2" + local.multi_runner_config.ec2.compute_provider.ec2.user_data.pre_install == "caller-pre" + && startswith(local.multi_runner_config.ec2.compute_provider.ec2.user_data.post_install, "caller-post\n") + && strcontains(local.multi_runner_config.ec2.compute_provider.ec2.user_data.post_install, "su -l root -c") + && strcontains(local.multi_runner_config.ec2.compute_provider.ec2.user_data.post_install, "--config /root/.docker") + && local.multi_runner_config.ec2.compute_provider.ec2.user_data.debug_logging_enabled + && length(local.multi_runner_config.ec2.compute_provider.ec2.log_files) == 4 + && local.multi_runner_config.ec2.compute_provider.ec2.log_files[3].file_path == "/root/hook.log" + && local.multi_runner_config.ec2.compute_provider.ec2.tags.Environment == "test" + && local.multi_runner_config.ec2.compute_provider.ec2.tags.Lane == "ec2" ) error_message = "The v2 configuration must retain Forge user-data, logging, and tag overlays." } @@ -420,60 +518,141 @@ run "ec2_v2_input_plan" { assert { condition = ( startswith( - local.multi_runner_config_v2.ec2.runner.hooks.job_started, + local.multi_runner_config.ec2.runner.hooks.job_started, "printf '%s' '${base64encode("echo caller-started\nexit 0")}' | base64 --decode | bash\n", ) && startswith( - local.multi_runner_config_v2.ec2.runner.hooks.job_completed, + local.multi_runner_config.ec2.runner.hooks.job_completed, "printf '%s' '${base64encode("echo caller-completed")}' | base64 --decode | bash\n", ) - && local.multi_runner_config_v2.ec2.runner.iam.managed_policy_arns.caller == "arn:aws:iam::123456789012:policy/caller" - && local.multi_runner_config_v2.ec2.runner.iam.managed_policy_arns.forge_ec2_tags == "arn:aws:iam::123456789012:policy/mock" - && local.multi_runner_config_v2.ec2.runner.iam.managed_policy_arns.forge_runner_hooks_ssm_read == "arn:aws:iam::123456789012:policy/mock" + && local.multi_runner_config.ec2.runner.iam.managed_policy_arns.caller == "arn:aws:iam::123456789012:policy/caller" + && local.multi_runner_config.ec2.runner.iam.managed_policy_arns.forge_ec2_tags == "arn:aws:iam::123456789012:policy/mock" + && local.multi_runner_config.ec2.runner.iam.managed_policy_arns.forge_runner_hooks_ssm_read == "arn:aws:iam::123456789012:policy/mock" ) error_message = "Caller hooks must be isolated from Forge's required lifecycle hooks, and Forge policies must augment caller policies." } assert { condition = ( - length(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers) == 2 - && tolist(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) - && tolist(local.multi_runner_config_v2.ec2.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) - && local.multi_runner_config_v2.ec2.matcherConfig.exactMatch - && local.multi_runner_config_v2.ec2.matcherConfig.bidirectionalLabelMatch - && local.multi_runner_config_v2.ec2.matcherConfig.priority == 5 - && local.multi_runner_config_v2.ec2.matcherConfig.enableDynamicLabels - && tolist(local.multi_runner_config_v2.ec2.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) + length(local.multi_runner_config.ec2.matcherConfig.labelMatchers) == 2 + && tolist(local.multi_runner_config.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) + && tolist(local.multi_runner_config.ec2.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) + && local.multi_runner_config.ec2.matcherConfig.exactMatch + && local.multi_runner_config.ec2.matcherConfig.bidirectionalLabelMatch + && local.multi_runner_config.ec2.matcherConfig.priority == 5 + && local.multi_runner_config.ec2.matcherConfig.enableDynamicLabels + && tolist(local.multi_runner_config.ec2.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) ) error_message = "The v2 configuration must preserve matcher configuration." } assert { condition = ( - local.multi_runner_config_v2.ec2.tags.Scope == "entry" - && local.multi_runner_config_v2.ec2.runner.tags.Scope == "runner" - && local.multi_runner_config_v2.ec2.lambda.tags.Scope == "lambda" - && local.multi_runner_config_v2.ec2.queue.tags.Scope == "queue" - && local.multi_runner_config_v2.ec2.scale_up.tags.Scope == "scale-up" - && local.multi_runner_config_v2.ec2.scale_down.tags.Scope == "scale-down" - && local.multi_runner_config_v2.ec2.pool.tags.Scope == "pool" - && local.multi_runner_config_v2.ec2.job_retry.tags.Scope == "job-retry" - && local.multi_runner_config_v2.ec2.ssm.tags.Scope == "ssm" - && local.multi_runner_config_v2.ec2.ssm.parameters.tags.Scope == "ssm-parameters" - && local.multi_runner_config_v2.ec2.ssm.housekeeper.tags.Scope == "ssm-housekeeper" - && local.multi_runner_config_v2.ec2.observability.logs.tags.Scope == "logs" + local.multi_runner_config.ec2.tags.Scope == "entry" + && local.multi_runner_config.ec2.runner.tags.Scope == "runner" + && local.multi_runner_config.ec2.lambda.tags.Scope == "lambda" + && local.multi_runner_config.ec2.queue.tags.Scope == "queue" + && local.multi_runner_config.ec2.lambda.scale_up.tags.Scope == "scale-up" + && local.multi_runner_config.ec2.lambda.scale_down.tags.Scope == "scale-down" + && local.multi_runner_config.ec2.lambda.pool.tags.Scope == "pool" + && local.multi_runner_config.ec2.job_retry.tags.Scope == "job-retry" + && local.multi_runner_config.ec2.ssm.tags.Scope == "ssm" + && local.multi_runner_config.ec2.ssm.parameters.tags.Scope == "ssm-parameters" + && local.multi_runner_config.ec2.ssm.housekeeper.tags.Scope == "ssm-housekeeper" + && local.multi_runner_config.ec2.observability.logs.tags.Scope == "logs" ) error_message = "The v2 configuration must preserve every component tag scope." } assert { condition = ( - local.multi_runner_config_v2.ec2.runner.iam.additional_trust_policy_json == "{\"Version\":\"2012-10-17\",\"Statement\":[]}" - && local.multi_runner_config_v2.ec2.runner.iam.path == "/forge/" - && local.multi_runner_config_v2.ec2.runner.iam.permissions_boundary == "arn:aws:iam::123456789012:policy/boundary" - && local.multi_runner_config_v2.ec2.ssm.kms_key.arn == "arn:aws:kms:eu-west-1:123456789012:key/00000000-0000-0000-0000-000000000000" + local.multi_runner_config.ec2.runner.iam.additional_trust_policy_json == "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + && local.multi_runner_config.ec2.runner.iam.path == "/forge/" + && local.multi_runner_config.ec2.runner.iam.permissions_boundary == "arn:aws:iam::123456789012:policy/boundary" + ) + error_message = "The v2 configuration must preserve lane IAM ownership settings." + } + + assert { + condition = ( + local.multi_runner_config.ec2.lambda.runtime == "nodejs22.x" + && local.multi_runner_config.ec2.lambda.architecture == "x86_64" + && tolist(local.multi_runner_config.ec2.lambda.subnet_ids) == tolist(["subnet-lambda-override"]) + && tolist(local.multi_runner_config.ec2.lambda.security_group_ids) == tolist(["sg-lambda"]) + && local.multi_runner_config.ec2.lambda.role.path == "/lambda/" + && local.multi_runner_config.ec2.lambda.role.permissions_boundary == "arn:aws:iam::123456789012:policy/lambda-boundary" + ) + error_message = "The v2 configuration must preserve lane Lambda runtime, network, and role overrides." + } + + assert { + condition = ( + local.multi_runner_config.ec2.ssm.paths.root == "/custom-root" + && local.multi_runner_config.ec2.ssm.paths.tokens == "custom-tokens" + && local.multi_runner_config.ec2.ssm.paths.config == "custom-config" + && local.multi_runner_config.ec2.ssm.housekeeper.schedule_expression == "rate(12 hours)" + && local.multi_runner_config.ec2.ssm.housekeeper.state == "DISABLED" + && local.multi_runner_config.ec2.ssm.housekeeper.lambda.memory_size == 384 + && local.multi_runner_config.ec2.ssm.housekeeper.lambda.timeout == 70 + && local.multi_runner_config.ec2.ssm.housekeeper.config.tokenPath == "custom-token-path" + && local.multi_runner_config.ec2.ssm.housekeeper.config.minimumDaysOld == 2 + && local.multi_runner_config.ec2.ssm.housekeeper.config.dryRun + ) + error_message = "The v2 configuration must preserve lane SSM paths and housekeeper overrides." + } + + assert { + condition = ( + local.multi_runner_config.ec2.observability.logs.level == "debug" + && local.multi_runner_config.ec2.observability.logs.retention_in_days == 14 + && local.multi_runner_config.ec2.observability.logs.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/22222222-2222-2222-2222-222222222222" + && local.multi_runner_config.ec2.observability.logs.class == "INFREQUENT_ACCESS" + && local.multi_runner_config.ec2.observability.tracing.mode == "Active" + && !local.multi_runner_config.ec2.observability.tracing.capture_http_requests + && !local.multi_runner_config.ec2.observability.tracing.capture_error + && !local.multi_runner_config.ec2.observability.metrics.enable + && local.multi_runner_config.ec2.observability.metrics.namespace == "Forge/Test" + && !local.multi_runner_config.ec2.observability.metrics.metric.enable_github_app_rate_limit + && local.multi_runner_config.ec2.observability.metrics.metric.enable_job_retry + ) + error_message = "The v2 configuration must preserve lane observability overrides." + } + + assert { + condition = ( + local.experimental_config.github.app.id == "12345" + && local.experimental_config.enterprise_server.url == null + && local.experimental_config.webhook.eventbridge.enable + && local.experimental_config.lambda.scale.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" + && tolist(local.experimental_config.lambda.subnet_ids) == tolist(["subnet-test"]) + && length(local.experimental_config.lambda.security_group_ids) == 1 + && local.experimental_config.lambda.webhook.artifact.zip == "/private/tmp/forge-test-lambda-cache/webhook.zip" + && local.experimental_config.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/00000000-0000-0000-0000-000000000000" + && local.experimental_config.observability.logs.level == "info" + && local.experimental_config.observability.logs.retention_in_days == 3 + && local.experimental_config.compute_provider.ec2.vpc_id == "vpc-test" + && tolist(local.experimental_config.compute_provider.ec2.subnet_ids) == tolist(["subnet-default"]) + && local.experimental_config.compute_provider.ec2.runner_binaries.syncer.artifact.zip == "/private/tmp/forge-test-lambda-cache/runner-binaries-syncer.zip" + && local.experimental_config.multi_runner_config == local.multi_runner_config + ) + error_message = "Forge must populate the authoritative global experimental contract." + } + + assert { + condition = ( + local.experimental_config.tags == local.terraform_aws_github_runner_tags + && local.experimental_config.lambda.tags == local.terraform_aws_github_runner_tags + && local.experimental_config.ssm.parameters.tags == local.terraform_aws_github_runner_tags + ) + error_message = "Forge resource tags must be carried by the experimental global, Lambda, and Parameter Store tag maps." + } + + assert { + condition = ( + local.experimental_config.lambda.webhook.api_gateway_access_log_settings.destination_arn == aws_cloudwatch_log_group.webhook_api_gateway_access.arn + && local.experimental_config.lambda.webhook.api_gateway_access_log_settings.format == local.webhook_api_gateway_access_log_format ) - error_message = "The v2 configuration must preserve IAM ownership settings and default SSM encryption to Forge's KMS key." + error_message = "Forge webhook API Gateway access-log settings must be carried by the experimental webhook contract." } } diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index fedd20a4..a29a5703 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -47,43 +47,50 @@ run "platform_ec2_deployment_interface_contract" { "runner = object({", "os = string", "architecture = string", - "boot_time_in_minutes = optional(number, 5)", - "disable_default_labels = optional(bool, false)", - "extra_labels = optional(list(string), [])", - "group_name = optional(string, \"Default\")", - "name_prefix = optional(string, \"\")", - "run_as_root = optional(bool, false)", - "run_as = optional(string, \"ec2-user\")", + "boot_time_in_minutes = optional(number, null)", + "disable_default_labels = optional(bool, null)", + "extra_labels = optional(list(string), null)", + "group_name = optional(string, null)", + "name_prefix = optional(string, null)", + "run_as_root = optional(bool, null)", + "run_as = optional(string, null)", "maximum_count = number", - "ephemeral = optional(bool, false)", + "ephemeral = optional(bool, null)", "jit_config_enabled = optional(bool, null)", - "auto_update_disabled = optional(bool, false)", + "auto_update_disabled = optional(bool, null)", "hooks = optional(object({", - "job_started = optional(string, \"\")", - "job_completed = optional(string, \"\")", + "job_started = optional(string, null)", + "job_completed = optional(string, null)", "iam = optional(object({", "role = optional(object({", - "managed_policy_arns = optional(map(string), {})", + "managed_policy_arns = optional(map(string), null)", "additional_trust_policy_json = optional(string, null)", "path = optional(string, null)", "permissions_boundary = optional(string, null)", "github = optional(object({", "organization_runners = optional(bool, false)", "lambda = optional(object({", + "runtime = optional(string, null)", + "architecture = optional(string, null)", + "security_group_ids = optional(list(string), null)", + "role = optional(object({", + "memory_size = optional(number, null)", + "timeout = optional(number, null)", "queue = optional(object({", - "delay_webhook_event = optional(number, 30)", - "job_queue_retention_in_seconds = optional(number, 86400)", + "delay_webhook_event = optional(number, null)", + "job_queue_retention_in_seconds = optional(number, null)", + "visibility_timeout_seconds = optional(number, null)", "event_source_mapping = optional(object({", "batch_size = optional(number, null)", "maximum_batching_window_in_seconds = optional(number, null)", "redrive_build_queue = optional(object({", - "enabled = bool", - "maxReceiveCount = number", + "enabled = optional(bool, null)", + "maxReceiveCount = optional(number, null)", "scale_up = optional(object({", - "reserved_concurrent_executions = optional(number, 1)", + "reserved_concurrent_executions = optional(number, null)", "job_queued_check_enabled = optional(bool, null)", "scale_down = optional(object({", - "schedule_expression = optional(string, \"cron(*/5 * * * ? *)\")", + "schedule_expression = optional(string, null)", "minimum_running_time_in_minutes = optional(number, null)", "idle_config = optional(list(object({", "cron = string", @@ -95,7 +102,8 @@ run "platform_ec2_deployment_interface_contract" { "schedule_expression = string", "schedule_expression_timezone = optional(string)", "size = number", - "runner_owner = optional(string, null)", + "runner_owner = optional(string, null)", + "include_busy_runners = optional(bool, null)", "job_retry = optional(object({", "enabled = optional(bool, false)", "delay_in_seconds = optional(number, 300)", @@ -104,11 +112,27 @@ run "platform_ec2_deployment_interface_contract" { "memory_size = optional(number, 256)", "timeout = optional(number, 30)", "ssm = optional(object({", - "kms_key = optional(object({", + "paths = optional(object({", + "root = optional(string, null)", + "tokens = optional(string, null)", + "config = optional(string, null)", "parameters = optional(object({", "housekeeper = optional(object({", + "state = optional(string, null)", + "tokenPath = optional(string, null)", + "minimumDaysOld = optional(number, null)", + "dryRun = optional(bool, null)", "observability = optional(object({", "logs = optional(object({", + "retention_in_days = optional(number, null)", + "class = optional(string, null)", + "tracing = optional(object({", + "capture_http_requests = optional(bool, null)", + "capture_error = optional(bool, null)", + "metrics = optional(object({", + "namespace = optional(string, null)", + "enable_github_app_rate_limit = optional(bool, null)", + "enable_job_retry = optional(bool, null)", "blocked_keys = optional(list(string), [])", "restricted_keys = optional(map(object({", "allowed = optional(list(string), [])", @@ -153,12 +177,17 @@ run "platform_ec2_deployment_interface_contract" { "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_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), null)", + "managed_security_group_enabled = optional(bool, null)", + "egress_rules = optional(list(object({", + "instance_profile_path = optional(string, null)", + "key_name = optional(string, null)", + "associate_public_ipv4_address = optional(bool, null)", "instance_profile = optional(object({", "name = string", "enable_on_demand_failover_for_errors = optional(list(string), [])", @@ -206,8 +235,9 @@ run "platform_ec2_deployment_interface_contract" { "effective_runner_home_directories = {", "forge_runner_hook_job_started = {", "runner_hook_job_started = {", - "textencodebase64(runner_config.runner.hooks.job_started, \"UTF-16LE\")", - "base64encode(runner_config.runner.hooks.job_started)", + "effective_runner_hooks = {", + "textencodebase64(local.effective_runner_hooks[key].job_started, \"UTF-16LE\")", + "base64encode(local.effective_runner_hooks[key].job_started)", "length(runner_config.compute_provider.ec2[*]) == 1", "length(runner_config.compute_provider.ec2.ami[*]) == 1", "&& length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0,", @@ -231,7 +261,7 @@ 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)", - "multi_runner_config_v2 = {", + "multi_runner_config = {", "key => merge(runner_config, {", "runner = merge(runner_config.runner, {", "iam = merge(runner_config.runner.iam, {", @@ -240,12 +270,22 @@ run "platform_ec2_deployment_interface_contract" { "forge_ec2_tags = aws_iam_policy.ec2_tags.arn", "forge_runner_hooks_ssm_read = aws_iam_policy.runner_hooks_ssm_read.arn", "ec2 = local.ec2_compute_provider[key]", - "ssm = merge(runner_config.ssm, {", - "kms_key = runner_config.ssm.kms_key == null ? {", "runner_home = local.effective_runner_home_directories[key]", "job_started = local.runner_hook_job_started[key]", + "runner_ssm_paths = {", + "runner_ami_ssm_parameter_names = {", + "root = \"$${trimsuffix(coalesce(runner_config.ssm.paths.root, \"/github-action-runners/$${var.runner_configs.prefix}\"), \"/\")}/$${key}\"", "multi_runner_config = {}", - "multi_runner_config_v2 = local.multi_runner_config_v2", + "experimental = local.experimental_config", + "experimental_config = {", + "app = var.runner_configs.github_app", + "url = try(trimspace(var.runner_configs.ghes_url), \"\") == \"\" ? null : var.runner_configs.ghes_url", + "artifact = {", + "kms_key_id = aws_kms_key.github.arn", + "level = lower(var.runner_configs.log_level)", + "retention_in_days = tonumber(var.runner_configs.logging_retention_in_days)", + "runner_binaries = {", + "multi_runner_config = local.multi_runner_config", "output \"ec2_runners_arn_map\"", "for runner_key, runner in module.runners.runners_map_v2 : runner_key => coalesce(", "try(local.ec2_runner_configs[runner_key].runner.iam.role.arn, null)", @@ -290,7 +330,7 @@ run "platform_ec2_deployment_interface_contract" { condition = ( output.expected_input_variable_count == 4 && output.expected_output_value_count == 6 - && output.expected_interface_literal_count == 236 + && output.expected_interface_literal_count == 276 ) 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 f57ab267..e76e4890 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -12,14 +12,21 @@ run "platform_ec2_deployment_contract" { "module \"ec2_update_runner_tags\"", "module \"runners\"", "source = \"git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=experimental-multi-runner-config-v2-20260805\"", - "experimental = {", "multi_runner_config = {}", - "multi_runner_config_v2 = local.multi_runner_config_v2", + "experimental = local.experimental_config", + "experimental_config = {", + "app = var.runner_configs.github_app", + "kms_key_id = aws_kms_key.github.arn", + "multi_runner_config = local.multi_runner_config", "resource \"aws_cloudwatch_log_group\" \"webhook_api_gateway_access\"", "resource \"aws_kms_key\" \"github\"", "resource \"aws_kms_alias\" \"github\"", "resource \"aws_ssm_parameter\" \"hook_job_started\"", "resource \"aws_ssm_parameter\" \"hook_job_completed\"", + "runner_ssm_paths = {", + "root = \"$${trimsuffix(coalesce(runner_config.ssm.paths.root, \"/github-action-runners/$${var.runner_configs.prefix}\"), \"/\")}/$${key}\"", + "runner_ami_ssm_parameter_names = {", + "key => \"$${paths.root}/$${paths.config}/ami_id\"", "resource \"aws_iam_policy\" \"runner_hooks_ssm_read\"", "resource \"aws_iam_policy\" \"ec2_tags\"", "resource \"aws_security_group\" \"gh_runner_lambda_egress\"", diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index dbfd523a..eabcc3b6 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -22,27 +22,27 @@ variable "runner_configs" { runner = object({ os = string architecture = string - boot_time_in_minutes = optional(number, 5) - disable_default_labels = optional(bool, false) - extra_labels = optional(list(string), []) - group_name = optional(string, "Default") - name_prefix = optional(string, "") - run_as_root = optional(bool, false) - run_as = optional(string, "ec2-user") + boot_time_in_minutes = optional(number, null) + disable_default_labels = optional(bool, null) + extra_labels = optional(list(string), null) + group_name = optional(string, null) + name_prefix = optional(string, null) + run_as_root = optional(bool, null) + run_as = optional(string, null) maximum_count = number - ephemeral = optional(bool, false) + ephemeral = optional(bool, null) jit_config_enabled = optional(bool, null) - auto_update_disabled = optional(bool, false) + auto_update_disabled = optional(bool, null) tags = optional(map(string), {}) hooks = optional(object({ - job_started = optional(string, "") - job_completed = optional(string, "") + job_started = optional(string, null) + job_completed = optional(string, null) }), {}) iam = optional(object({ role = optional(object({ arn = string }), null) - managed_policy_arns = optional(map(string), {}) + managed_policy_arns = optional(map(string), null) additional_trust_policy_json = optional(string, null) path = optional(string, null) permissions_boundary = optional(string, null) @@ -54,54 +54,65 @@ variable "runner_configs" { }), {}) lambda = optional(object({ - tags = optional(map(string), {}) + runtime = optional(string, null) + architecture = optional(string, null) + subnet_ids = optional(list(string), null) + security_group_ids = optional(list(string), null) + tags = optional(map(string), {}) + role = optional(object({ + path = optional(string, null) + permissions_boundary = optional(string, null) + }), {}) + scale_up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + scale_down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) + }), {}) + pool = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + config = optional(list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })), null) + include_busy_runners = optional(bool, null) + runner_owner = optional(string, null) + tags = optional(map(string), {}) + }), {}) }), {}) queue = optional(object({ - delay_webhook_event = optional(number, 30) - job_queue_retention_in_seconds = optional(number, 86400) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) - }), {}) + delay_webhook_event = optional(number, null) + job_queue_retention_in_seconds = optional(number, null) + visibility_timeout_seconds = optional(number, null) redrive_build_queue = optional(object({ - enabled = bool - maxReceiveCount = number - }), { - enabled = false - maxReceiveCount = null - }) + enabled = optional(bool, null) + maxReceiveCount = optional(number, null) + }), null) tags = optional(map(string), {}) }), {}) - scale_up = optional(object({ - reserved_concurrent_executions = optional(number, 1) - job_queued_check_enabled = optional(bool, null) - tags = optional(map(string), {}) - }), {}) - - scale_down = optional(object({ - schedule_expression = optional(string, "cron(*/5 * * * ? *)") - minimum_running_time_in_minutes = optional(number, null) - tags = optional(map(string), {}) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), []) - }), {}) - - pool = optional(object({ - config = optional(list(object({ - schedule_expression = string - schedule_expression_timezone = optional(string) - size = number - })), []) - runner_owner = optional(string, null) - tags = optional(map(string), {}) - }), {}) - job_retry = optional(object({ enabled = optional(bool, false) delay_in_seconds = optional(number, 300) @@ -116,21 +127,51 @@ variable "runner_configs" { }), {}) ssm = optional(object({ + paths = optional(object({ + root = optional(string, null) + tokens = optional(string, null) + config = optional(string, null) + }), {}) tags = optional(map(string), {}) - kms_key = optional(object({ - arn = string - }), null) parameters = optional(object({ tags = optional(map(string), {}) }), {}) housekeeper = optional(object({ - tags = optional(map(string), {}) + schedule_expression = optional(string, null) + state = optional(string, null) + tags = optional(map(string), {}) + lambda = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + }), {}) + config = optional(object({ + tokenPath = optional(string, null) + minimumDaysOld = optional(number, null) + dryRun = optional(bool, null) + }), {}) }), {}) }), {}) observability = optional(object({ logs = optional(object({ - tags = optional(map(string), {}) + level = optional(string, null) + retention_in_days = optional(number, null) + kms_key_id = optional(string, null) + class = optional(string, null) + tags = optional(map(string), {}) + }), {}) + tracing = optional(object({ + mode = optional(string, null) + capture_http_requests = optional(bool, null) + capture_error = optional(bool, null) + }), {}) + metrics = optional(object({ + enable = optional(bool, null) + namespace = optional(string, null) + metric = optional(object({ + enable_github_app_rate_limit = optional(bool, null) + enable_job_retry = optional(bool, null) + }), {}) }), {}) }), {}) @@ -174,7 +215,7 @@ variable "runner_configs" { config = optional(string, null) }), {}) binaries_syncer = optional(object({ - enabled = optional(bool, true) + enabled = optional(bool, null) }), {}) detailed_monitoring_enabled = optional(bool, false) ssm_enabled = optional(bool, false) @@ -186,12 +227,27 @@ variable "runner_configs" { 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_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), null) + managed_security_group_enabled = optional(bool, null) + egress_rules = optional(list(object({ + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + prefix_list_ids = list(string) + from_port = number + protocol = string + security_groups = list(string) + self = bool + to_port = number + description = string + })), null) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, null) instance_profile = optional(object({ name = string }), null) diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index 2261e3ce..e7d00974 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -22,7 +22,12 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived - `ec2_deployment_specs.runner_specs` uses the full nested v2 shape; every entry must configure `runner`, `matcherConfig`, and `compute_provider.ec2`. - `ec2_deployment` enriches that public shape with Forge-managed settings and - passes it directly to upstream's experimental `multi_runner_config_v2`. + passes it through upstream's `experimental.multi_runner_config`; shared + GitHub App, Lambda artifact/network, SSM KMS, observability, and EC2 network + settings are populated in the experimental global blocks. +- Per-lane scale-up, scale-down, and pool settings are nested under `lambda`; + event-source batching is under `lambda.scale_up`, while queue visibility is + configured under `queue`. - The v2 runner graph changes internal Terraform addresses and has no upstream `moved` blocks. Use this ref for new state only until a state migration and exact-ref Lambda artifacts have been validated for existing deployments. @@ -121,7 +126,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. The public runner
shape follows the nested experimental multi\_runner\_config\_v2 EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 : Provider-neutral OS, architecture, labels, registration,
hooks, capacity, and IAM-policy configuration.
- github : Organization-versus-repository runner registration.
- queue : Webhook delay, retention, Lambda batching, and DLQ
redrive configuration.
- scale\_up : Scale-up concurrency and queued-job checks.
- scale\_down : Scale-down schedule, minimum runtime, and idle runners.
- pool : Scheduled warm-pool sizes and runner owner.
- job\_retry : Retry timing, attempts, and Lambda sizing.
- matcherConfig : Static and dynamic GitHub label matching.
- compute\_provider: Nested v2 EC2 provider configuration.
- tags/lambda/ssm/observability: Component-level tags, SSM configuration,
and CloudWatch Logs 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: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, 5)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
maximum_count = number
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
tags = optional(map(string), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
}), {})

scale_up = optional(object({
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
tags = optional(map(string), {})
}), {})

scale_down = optional(object({
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
tags = optional(map(string), {})
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
}), {})

pool = optional(object({
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
tags = optional(map(string), {})
kms_key = optional(object({
arn = string
}), null)
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
tags = optional(map(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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | EC2 deployment configuration for GitHub Actions runners. The public runner
shape follows the nested experimental multi\_runner\_config EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 : Provider-neutral OS, architecture, labels, registration,
hooks, capacity, and IAM-policy configuration.
- github : Organization-versus-repository runner registration.
- lambda : Per-lane Lambda defaults, with scale\_up, scale\_down, and
pool component configuration nested below it.
- queue : Webhook delay, retention, visibility timeout, and DLQ
redrive configuration.
- job\_retry : Retry timing, attempts, and Lambda sizing.
- ssm : Per-lane paths, tags, parameter tags, and housekeeper.
- observability : Per-lane logging, tracing, and metrics overrides.
- matcherConfig : Static and dynamic GitHub label matching.
- compute\_provider: Nested v2 EC2 provider configuration.
- tags : Per-lane resource tags.

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/security-group fields: Per-lane networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = number
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
})
| 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 9eec63d2..23dbceb0 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -143,43 +143,50 @@ run "platform_forge_runners_interface_contract" { "runner = object({", "os = string", "architecture = string", - "boot_time_in_minutes = optional(number, 5)", - "disable_default_labels = optional(bool, false)", - "extra_labels = optional(list(string), [])", - "group_name = optional(string, \"Default\")", - "name_prefix = optional(string, \"\")", - "run_as_root = optional(bool, false)", - "run_as = optional(string, \"ec2-user\")", + "boot_time_in_minutes = optional(number, null)", + "disable_default_labels = optional(bool, null)", + "extra_labels = optional(list(string), null)", + "group_name = optional(string, null)", + "name_prefix = optional(string, null)", + "run_as_root = optional(bool, null)", + "run_as = optional(string, null)", "maximum_count = number", - "ephemeral = optional(bool, false)", + "ephemeral = optional(bool, null)", "jit_config_enabled = optional(bool, null)", - "auto_update_disabled = optional(bool, false)", + "auto_update_disabled = optional(bool, null)", "hooks = optional(object({", - "job_started = optional(string, \"\")", - "job_completed = optional(string, \"\")", + "job_started = optional(string, null)", + "job_completed = optional(string, null)", "iam = optional(object({", "role = optional(object({", - "managed_policy_arns = optional(map(string), {})", + "managed_policy_arns = optional(map(string), null)", "additional_trust_policy_json = optional(string, null)", "path = optional(string, null)", "permissions_boundary = optional(string, null)", "github = optional(object({", "organization_runners = optional(bool, false)", "lambda = optional(object({", + "runtime = optional(string, null)", + "architecture = optional(string, null)", + "security_group_ids = optional(list(string), null)", + "role = optional(object({", + "memory_size = optional(number, null)", + "timeout = optional(number, null)", "queue = optional(object({", - "delay_webhook_event = optional(number, 30)", - "job_queue_retention_in_seconds = optional(number, 86400)", + "delay_webhook_event = optional(number, null)", + "job_queue_retention_in_seconds = optional(number, null)", + "visibility_timeout_seconds = optional(number, null)", "event_source_mapping = optional(object({", "batch_size = optional(number, null)", "maximum_batching_window_in_seconds = optional(number, null)", "redrive_build_queue = optional(object({", - "enabled = bool", - "maxReceiveCount = number", + "enabled = optional(bool, null)", + "maxReceiveCount = optional(number, null)", "scale_up = optional(object({", - "reserved_concurrent_executions = optional(number, 1)", + "reserved_concurrent_executions = optional(number, null)", "job_queued_check_enabled = optional(bool, null)", "scale_down = optional(object({", - "schedule_expression = optional(string, \"cron(*/5 * * * ? *)\")", + "schedule_expression = optional(string, null)", "minimum_running_time_in_minutes = optional(number, null)", "idle_config = optional(list(object({", "cron = string", @@ -191,7 +198,8 @@ run "platform_forge_runners_interface_contract" { "schedule_expression = string", "schedule_expression_timezone = optional(string)", "size = number", - "runner_owner = optional(string, null)", + "runner_owner = optional(string, null)", + "include_busy_runners = optional(bool, null)", "job_retry = optional(object({", "enabled = optional(bool, false)", "delay_in_seconds = optional(number, 300)", @@ -200,11 +208,27 @@ run "platform_forge_runners_interface_contract" { "memory_size = optional(number, 256)", "timeout = optional(number, 30)", "ssm = optional(object({", - "kms_key = optional(object({", + "paths = optional(object({", + "root = optional(string, null)", + "tokens = optional(string, null)", + "config = optional(string, null)", "parameters = optional(object({", "housekeeper = optional(object({", + "state = optional(string, null)", + "tokenPath = optional(string, null)", + "minimumDaysOld = optional(number, null)", + "dryRun = optional(bool, null)", "observability = optional(object({", "logs = optional(object({", + "retention_in_days = optional(number, null)", + "class = optional(string, null)", + "tracing = optional(object({", + "capture_http_requests = optional(bool, null)", + "capture_error = optional(bool, null)", + "metrics = optional(object({", + "namespace = optional(string, null)", + "enable_github_app_rate_limit = optional(bool, null)", + "enable_job_retry = optional(bool, null)", "blocked_keys = optional(list(string), [])", "restricted_keys = optional(map(object({", "allowed = optional(list(string), [])", @@ -249,12 +273,17 @@ run "platform_forge_runners_interface_contract" { "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_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), null)", + "managed_security_group_enabled = optional(bool, null)", + "egress_rules = optional(list(object({", + "instance_profile_path = optional(string, null)", + "key_name = optional(string, null)", + "associate_public_ipv4_address = optional(bool, null)", "instance_profile = optional(object({", "name = string", "enable_on_demand_failover_for_errors = optional(list(string), [])", @@ -305,13 +334,17 @@ run "platform_forge_runners_interface_contract" { "- vpc_id : VPC that contains both runner and lambda subnets.", "- runner_specs : Map of EC2 runner lanes.", "- runner : Provider-neutral OS, architecture, labels, registration,", - "- queue : Webhook delay, retention, Lambda batching, and DLQ", "- job_retry : Retry timing, attempts, and Lambda sizing.", "- matcherConfig : Static and dynamic GitHub label matching.", - "shape follows the nested experimental multi_runner_config_v2 EC2 contract and", + "shape follows the nested experimental multi_runner_config EC2 contract and", "is passed directly to the upstream provider-oriented runner stack.", + "- lambda : Per-lane Lambda defaults, with scale_up, scale_down, and", + "pool component configuration nested below it.", + "- queue : Webhook delay, retention, visibility timeout, and DLQ", + "- ssm : Per-lane paths, tags, parameter tags, and housekeeper.", + "- observability : Per-lane logging, tracing, and metrics overrides.", "- compute_provider: Nested v2 EC2 provider configuration.", - "- tags/lambda/ssm/observability: Component-level tags, SSM configuration,", + "- tags : Per-lane resource tags.", "compute_provider.ec2 fields:", "- ami : Upstream-compatible EC2 AMI configuration.", "- instance_profile: Optional externally managed instance profile. It requires", @@ -402,7 +435,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 == 343 + && output.expected_interface_literal_count == 376 ) 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 996fecf7..0002297a 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -19,7 +19,9 @@ run "platform_forge_runners_contract" { "resource \"random_id\" \"random\"", "runner_specs = var.ec2_deployment_specs.runner_specs", "runner = object({", + "lambda = optional(object({", "queue = optional(object({", + "visibility_timeout_seconds = optional(number, null)", "scale_up = optional(object({", "scale_down = optional(object({", "pool = optional(object({", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index 1102b2d5..c3ccd832 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -20,27 +20,27 @@ variable "ec2_deployment_specs" { runner = object({ os = string architecture = string - boot_time_in_minutes = optional(number, 5) - disable_default_labels = optional(bool, false) - extra_labels = optional(list(string), []) - group_name = optional(string, "Default") - name_prefix = optional(string, "") - run_as_root = optional(bool, false) - run_as = optional(string, "ec2-user") + boot_time_in_minutes = optional(number, null) + disable_default_labels = optional(bool, null) + extra_labels = optional(list(string), null) + group_name = optional(string, null) + name_prefix = optional(string, null) + run_as_root = optional(bool, null) + run_as = optional(string, null) maximum_count = number - ephemeral = optional(bool, false) + ephemeral = optional(bool, null) jit_config_enabled = optional(bool, null) - auto_update_disabled = optional(bool, false) + auto_update_disabled = optional(bool, null) tags = optional(map(string), {}) hooks = optional(object({ - job_started = optional(string, "") - job_completed = optional(string, "") + job_started = optional(string, null) + job_completed = optional(string, null) }), {}) iam = optional(object({ role = optional(object({ arn = string }), null) - managed_policy_arns = optional(map(string), {}) + managed_policy_arns = optional(map(string), null) additional_trust_policy_json = optional(string, null) path = optional(string, null) permissions_boundary = optional(string, null) @@ -52,54 +52,65 @@ variable "ec2_deployment_specs" { }), {}) lambda = optional(object({ - tags = optional(map(string), {}) + runtime = optional(string, null) + architecture = optional(string, null) + subnet_ids = optional(list(string), null) + security_group_ids = optional(list(string), null) + tags = optional(map(string), {}) + role = optional(object({ + path = optional(string, null) + permissions_boundary = optional(string, null) + }), {}) + scale_up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + scale_down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) + }), {}) + pool = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + config = optional(list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })), null) + include_busy_runners = optional(bool, null) + runner_owner = optional(string, null) + tags = optional(map(string), {}) + }), {}) }), {}) queue = optional(object({ - delay_webhook_event = optional(number, 30) - job_queue_retention_in_seconds = optional(number, 86400) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) - }), {}) + delay_webhook_event = optional(number, null) + job_queue_retention_in_seconds = optional(number, null) + visibility_timeout_seconds = optional(number, null) redrive_build_queue = optional(object({ - enabled = bool - maxReceiveCount = number - }), { - enabled = false - maxReceiveCount = null - }) + enabled = optional(bool, null) + maxReceiveCount = optional(number, null) + }), null) tags = optional(map(string), {}) }), {}) - scale_up = optional(object({ - reserved_concurrent_executions = optional(number, 1) - job_queued_check_enabled = optional(bool, null) - tags = optional(map(string), {}) - }), {}) - - scale_down = optional(object({ - schedule_expression = optional(string, "cron(*/5 * * * ? *)") - minimum_running_time_in_minutes = optional(number, null) - tags = optional(map(string), {}) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), []) - }), {}) - - pool = optional(object({ - config = optional(list(object({ - schedule_expression = string - schedule_expression_timezone = optional(string) - size = number - })), []) - runner_owner = optional(string, null) - tags = optional(map(string), {}) - }), {}) - job_retry = optional(object({ enabled = optional(bool, false) delay_in_seconds = optional(number, 300) @@ -114,21 +125,51 @@ variable "ec2_deployment_specs" { }), {}) ssm = optional(object({ + paths = optional(object({ + root = optional(string, null) + tokens = optional(string, null) + config = optional(string, null) + }), {}) tags = optional(map(string), {}) - kms_key = optional(object({ - arn = string - }), null) parameters = optional(object({ tags = optional(map(string), {}) }), {}) housekeeper = optional(object({ - tags = optional(map(string), {}) + schedule_expression = optional(string, null) + state = optional(string, null) + tags = optional(map(string), {}) + lambda = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + }), {}) + config = optional(object({ + tokenPath = optional(string, null) + minimumDaysOld = optional(number, null) + dryRun = optional(bool, null) + }), {}) }), {}) }), {}) observability = optional(object({ logs = optional(object({ - tags = optional(map(string), {}) + level = optional(string, null) + retention_in_days = optional(number, null) + kms_key_id = optional(string, null) + class = optional(string, null) + tags = optional(map(string), {}) + }), {}) + tracing = optional(object({ + mode = optional(string, null) + capture_http_requests = optional(bool, null) + capture_error = optional(bool, null) + }), {}) + metrics = optional(object({ + enable = optional(bool, null) + namespace = optional(string, null) + metric = optional(object({ + enable_github_app_rate_limit = optional(bool, null) + enable_job_retry = optional(bool, null) + }), {}) }), {}) }), {}) @@ -172,7 +213,7 @@ variable "ec2_deployment_specs" { config = optional(string, null) }), {}) binaries_syncer = optional(object({ - enabled = optional(bool, true) + enabled = optional(bool, null) }), {}) detailed_monitoring_enabled = optional(bool, false) ssm_enabled = optional(bool, false) @@ -184,12 +225,27 @@ variable "ec2_deployment_specs" { 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_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), null) + managed_security_group_enabled = optional(bool, null) + egress_rules = optional(list(object({ + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + prefix_list_ids = list(string) + from_port = number + protocol = string + security_groups = list(string) + self = bool + to_port = number + description = string + })), null) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, null) instance_profile = optional(object({ name = string }), null) @@ -272,7 +328,7 @@ variable "ec2_deployment_specs" { description = <<-EOT EC2 deployment configuration for GitHub Actions runners. The public runner - shape follows the nested experimental multi_runner_config_v2 EC2 contract and + shape follows the nested experimental multi_runner_config EC2 contract and is passed directly to the upstream provider-oriented runner stack. Top-level fields: @@ -286,16 +342,16 @@ variable "ec2_deployment_specs" { - runner : Provider-neutral OS, architecture, labels, registration, hooks, capacity, and IAM-policy configuration. - github : Organization-versus-repository runner registration. - - queue : Webhook delay, retention, Lambda batching, and DLQ + - lambda : Per-lane Lambda defaults, with scale_up, scale_down, and + pool component configuration nested below it. + - queue : Webhook delay, retention, visibility timeout, and DLQ redrive configuration. - - scale_up : Scale-up concurrency and queued-job checks. - - scale_down : Scale-down schedule, minimum runtime, and idle runners. - - pool : Scheduled warm-pool sizes and runner owner. - job_retry : Retry timing, attempts, and Lambda sizing. + - ssm : Per-lane paths, tags, parameter tags, and housekeeper. + - observability : Per-lane logging, tracing, and metrics overrides. - matcherConfig : Static and dynamic GitHub label matching. - compute_provider: Nested v2 EC2 provider configuration. - - tags/lambda/ssm/observability: Component-level tags, SSM configuration, - and CloudWatch Logs configuration. + - tags : Per-lane resource tags. compute_provider.ec2 fields: - ami : Upstream-compatible EC2 AMI configuration. @@ -305,7 +361,7 @@ variable "ec2_deployment_specs" { - 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. + - vpc_id/subnet_ids/security-group fields: Per-lane networking. - cpu_options/placement/license_specifications: EC2 launch-template options. - instance_profile: Optional externally managed instance profile. It requires an externally managed runner IAM role whose owner also From 592053e72b281a029e6212ebd3afbfc9683eb5ed Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 15:52:32 +0200 Subject: [PATCH 4/8] refactor(runners): adopt orchestration provider contract --- .../main/tenants/acme/runner_settings.hcl | 126 ++++---- .../platform/tenant/runner_settings.hcl | 126 ++++---- modules/platform/ec2_deployment/README.md | 57 ++-- .../ec2_deployment/runner_configs_v2.tf | 54 ++-- .../ec2_deployment/tests/behavior.tftest.hcl | 301 ++++++++++-------- .../tests/interface_contract.tftest.hcl | 17 +- .../tests/source_inventory.tftest.hcl | 5 + modules/platform/ec2_deployment/variables.tf | 187 ++++++----- modules/platform/forge_runners/README.md | 31 +- .../tests/interface_contract.tftest.hcl | 32 +- .../tests/source_inventory.tftest.hcl | 4 + modules/platform/forge_runners/variables.tf | 212 ++++++------ 12 files changed, 652 insertions(+), 500 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 87b05553..a9d8bf07 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 @@ -73,37 +73,74 @@ locals { "vpc:${local.vpc_alias}", "tnt:${local.tenant_name}", ] - group_name = local.runner_group_name - run_as = spec.runner_user - maximum_count = spec.max_instances - ephemeral = true + group_name = local.runner_group_name + run_as = spec.runner_user + ephemeral = true } - github = { - organization_runners = true - } - lambda = { - scale_up = { - job_queued_check_enabled = false - event_source_mapping = { - batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + orchestration = { + webhook = { + runner = { + maximum_count = spec.max_instances + } + github = { + organization_runners = true + } + lambda = { + scale_up = { + job_queued_check_enabled = false + event_source_mapping = { + batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + } + } + scale_down = { + minimum_running_time_in_minutes = 30 + } + pool = { + config = spec.pool_config + runner_owner = local.config.gh_config.ghes_org + } + } + queue = { + delay_webhook_event = 0 + job_queue_retention_in_seconds = 172800 + visibility_timeout_seconds = 180 + redrive_build_queue = { + enabled = try(spec.redrive_build_queue.enabled, true) + maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) + } + } + matcherConfig = { + labelMatchers = concat( + [[ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ]], + concat([ + for label_count in range(1, 5) : concat([ + for start in range(0, 5 - label_count) : concat( + [ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ], + slice([ + "ec2", + "rgn:${local.region_alias}", + "vpc:${local.vpc_alias}", + "tnt:${local.tenant_name}", + ], start, start + label_count), + ) + ]) + ]...), + ) + exactMatch = true + enableDynamicLabels = try(spec.enable_dynamic_labels, false) + awsDynamicLabelsPolicy = try(spec.aws_dynamic_labels_policy, null) } - } - scale_down = { - minimum_running_time_in_minutes = 30 - } - pool = { - config = spec.pool_config - runner_owner = local.config.gh_config.ghes_org - } - } - queue = { - delay_webhook_event = 0 - job_queue_retention_in_seconds = 172800 - visibility_timeout_seconds = 180 - redrive_build_queue = { - enabled = try(spec.redrive_build_queue.enabled, true) - maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) } } compute_provider = { @@ -158,37 +195,6 @@ locals { }] } } - matcherConfig = { - labelMatchers = concat( - [[ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ]], - concat([ - for label_count in range(1, 5) : concat([ - for start in range(0, 5 - label_count) : concat( - [ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ], - slice([ - "ec2", - "rgn:${local.region_alias}", - "vpc:${local.vpc_alias}", - "tnt:${local.tenant_name}", - ], start, start + label_count), - ) - ]) - ]...), - ) - exactMatch = true - enableDynamicLabels = try(spec.enable_dynamic_labels, false) - awsDynamicLabelsPolicy = try(spec.aws_dynamic_labels_policy, null) - } } } diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index 87b05553..a9d8bf07 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -73,37 +73,74 @@ locals { "vpc:${local.vpc_alias}", "tnt:${local.tenant_name}", ] - group_name = local.runner_group_name - run_as = spec.runner_user - maximum_count = spec.max_instances - ephemeral = true + group_name = local.runner_group_name + run_as = spec.runner_user + ephemeral = true } - github = { - organization_runners = true - } - lambda = { - scale_up = { - job_queued_check_enabled = false - event_source_mapping = { - batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + orchestration = { + webhook = { + runner = { + maximum_count = spec.max_instances + } + github = { + organization_runners = true + } + lambda = { + scale_up = { + job_queued_check_enabled = false + event_source_mapping = { + batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + } + } + scale_down = { + minimum_running_time_in_minutes = 30 + } + pool = { + config = spec.pool_config + runner_owner = local.config.gh_config.ghes_org + } + } + queue = { + delay_webhook_event = 0 + job_queue_retention_in_seconds = 172800 + visibility_timeout_seconds = 180 + redrive_build_queue = { + enabled = try(spec.redrive_build_queue.enabled, true) + maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) + } + } + matcherConfig = { + labelMatchers = concat( + [[ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ]], + concat([ + for label_count in range(1, 5) : concat([ + for start in range(0, 5 - label_count) : concat( + [ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ], + slice([ + "ec2", + "rgn:${local.region_alias}", + "vpc:${local.vpc_alias}", + "tnt:${local.tenant_name}", + ], start, start + label_count), + ) + ]) + ]...), + ) + exactMatch = true + enableDynamicLabels = try(spec.enable_dynamic_labels, false) + awsDynamicLabelsPolicy = try(spec.aws_dynamic_labels_policy, null) } - } - scale_down = { - minimum_running_time_in_minutes = 30 - } - pool = { - config = spec.pool_config - runner_owner = local.config.gh_config.ghes_org - } - } - queue = { - delay_webhook_event = 0 - job_queue_retention_in_seconds = 172800 - visibility_timeout_seconds = 180 - redrive_build_queue = { - enabled = try(spec.redrive_build_queue.enabled, true) - maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) } } compute_provider = { @@ -158,37 +195,6 @@ locals { }] } } - matcherConfig = { - labelMatchers = concat( - [[ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ]], - concat([ - for label_count in range(1, 5) : concat([ - for start in range(0, 5 - label_count) : concat( - [ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ], - slice([ - "ec2", - "rgn:${local.region_alias}", - "vpc:${local.vpc_alias}", - "tnt:${local.tenant_name}", - ], start, start + label_count), - ) - ]) - ]...), - ) - exactMatch = true - enableDynamicLabels = try(spec.enable_dynamic_labels, false) - awsDynamicLabelsPolicy = try(spec.aws_dynamic_labels_policy, null) - } } } diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index e2bafa19..d1aed69a 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -5,43 +5,52 @@ This module deploys Forge EC2 runner pools through the upstream ## Why This Module Exists -The experimental input lets each runner lane configure runner, GitHub, queue, -Lambda scaling and pools, retry, SSM, observability, matcher, and EC2 provider -blocks while sharing Forge's webhook and runner control plane. Forge enriches -each lane with its required hooks, IAM policies, bootstrap content, logging, and -tags, then passes the canonical map through `experimental.multi_runner_config`. -Global experimental settings own the GitHub App, Lambda artifacts and network, -SSM KMS key, logging defaults, and EC2 network defaults. EC2 supports custom -AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. +The experimental input lets each runner configuration select a typed +orchestration provider and a typed compute provider. Webhook orchestration owns +capacity, GitHub scope, label matching, queues, scale-up, scale-down, pool, and +retry settings. The common Lambda block remains provider-neutral and owns only +runtime, architecture, networking, role, and tag overrides. Forge enriches each +configuration with its required hooks, IAM policies, bootstrap content, logging, +and tags, then passes the canonical map through +`experimental.multi_runner_config`. + +Global experimental settings own the GitHub App and API client, common Lambda +substrate, webhook orchestration defaults and artifacts, the SSM housekeeper +artifact and KMS key, logging defaults, and EC2 network defaults. 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, warm pool schedules, and capacity limits. +- Per-configuration label matching, warm pool schedules, and capacity limits. - EC2 AMI, instance type, storage, user data, tag, and logging-hook configuration. - 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 uses the full - nested v2 shape and must contain `runner`, `matcherConfig`, and - `compute_provider.ec2`. Scale-up, scale-down, and pool settings live below - `lambda`; event-source batching lives below `lambda.scale_up`; queue - visibility is configured by `queue.visibility_timeout_seconds`. The legacy - flat EC2 shape is not accepted. -- Forge still owns scheduled AMI refresh, so EC2 lanes require a non-null, - module-managed `ami` block and cannot select `ami.id_ssm_parameter`. The + nested v2 shape and must contain `runner`, `orchestration.webhook`, and + `compute_provider.ec2`. `orchestration.webhook.runner.maximum_count` is the + webhook capacity limit. Matching, queue, scale-up, scale-down, pool, and retry + settings stay inside that provider block. The legacy flat EC2 shape is not + accepted. +- The SSM housekeeper has its own artifact selector under + `ssm.housekeeper.lambda.artifact`; Forge points the global selector at the + runner control-plane archive. +- Forge still owns scheduled AMI refresh, so EC2 runner configurations require + a non-null, module-managed `ami` block and cannot select + `ami.id_ssm_parameter`. The 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. - External runner roles and instance profiles are supported as a pair. Their owner must provision Forge's EC2-tag and hook-SSM permissions because the module does not modify externally managed IAM resources. -- The upstream v2 ref is experimental and mutable. It moves lane resources from - `module.runners` to `module.runner_stacks` and currently provides no Terraform - `moved` blocks. Use it for new state only; existing deployments need a - separately reviewed state migration to avoid create/destroy plans and name - collisions. Preserving lane keys alone does not preserve internal addresses. +- The upstream v2 ref is experimental and mutable. This revision moves the + prior experimental `module.runner_stacks` address to + `module.runner_configs`, but migration from the stable v1 graph remains a + separate review. Inspect existing-state plans for unexpected replacement or + naming collisions before rollout. - Forge currently supplies the v7.10.1 Lambda release artifacts to the experimental Terraform graph. Keep rollout gated until artifact compatibility is verified or exact-ref artifacts are published. @@ -63,8 +72,8 @@ AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.59.0 | -| [external](#provider\_external) | 2.4.0 | +| [aws](#provider\_aws) | 6.60.0 | +| [external](#provider\_external) | 2.4.1 | ## Modules @@ -101,7 +110,7 @@ AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. | ---- | ----------- | ---- | ------- | :------: | | [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
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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = number
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
})
| n/a | yes | +| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = 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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = object({
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| 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/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf index 6028e6f4..df8fe17d 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -45,7 +45,7 @@ locals { # Run caller hooks in a child shell so `exit` cannot bypass Forge's mandatory # lifecycle hook. Keep the empty-hook path byte-identical to the previous - # configuration to avoid state churn for migrated lanes. + # configuration to avoid state churn for migrated runner configurations. runner_hook_job_started = { for key, runner_config in local.ec2_runner_configs : key => local.effective_runner_hooks[key].job_started == "" ? local.forge_runner_hook_job_started[key] : join("\n", [ @@ -91,7 +91,7 @@ locals { legacy_runner_labels = { for key, runner_config in local.ec2_runner_configs : key => concat( - try(runner_config.matcherConfig.labelMatchers[0], []), + try(runner_config.orchestration.webhook.matcherConfig.labelMatchers[0], []), coalesce(runner_config.runner.extra_labels, []), ) } @@ -105,7 +105,7 @@ locals { local.legacy_runner_labels[key], distinct([ for label in flatten([ - for matcher_index, labels in runner_config.matcherConfig.labelMatchers : labels if matcher_index > 0 + for matcher_index, labels in runner_config.orchestration.webhook.matcherConfig.labelMatchers : labels if matcher_index > 0 ]) : label if !contains(local.legacy_runner_labels[key], label) ]), @@ -223,34 +223,37 @@ locals { github = { app = var.runner_configs.github_app - } - - enterprise_server = { - url = try(trimspace(var.runner_configs.ghes_url), "") == "" ? null : var.runner_configs.ghes_url - } - - webhook = { - eventbridge = { - enable = true + enterprise_server = { + url = try(trimspace(var.runner_configs.ghes_url), "") == "" ? null : var.runner_configs.ghes_url } } lambda = { - scale = { - artifact = { - zip = "${data.external.download_lambdas.result.path}/runners.zip" - } - } subnet_ids = var.network_configs.lambda_subnet_ids security_group_ids = [aws_security_group.gh_runner_lambda_egress.id] tags = local.terraform_aws_github_runner_tags + } + + orchestration = { webhook = { - artifact = { - zip = "${data.external.download_lambdas.result.path}/webhook.zip" + eventbridge = { + enable = true } - api_gateway_access_log_settings = { - destination_arn = aws_cloudwatch_log_group.webhook_api_gateway_access.arn - format = local.webhook_api_gateway_access_log_format + lambda = { + scale = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/runners.zip" + } + } + webhook = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/webhook.zip" + } + api_gateway_access_log_settings = { + destination_arn = aws_cloudwatch_log_group.webhook_api_gateway_access.arn + format = local.webhook_api_gateway_access_log_format + } + } } } } @@ -260,6 +263,13 @@ locals { parameters = { tags = local.terraform_aws_github_runner_tags } + housekeeper = { + lambda = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/runners.zip" + } + } + } } observability = { diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index d0755e30..8758587a 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -137,7 +137,6 @@ variables { name_prefix = "forge-" run_as_root = true run_as = "ec2-user" - maximum_count = 2 ephemeral = true jit_config_enabled = false auto_update_disabled = true @@ -155,9 +154,6 @@ variables { permissions_boundary = "arn:aws:iam::123456789012:policy/boundary" } } - github = { - organization_runners = true - } lambda = { runtime = "nodejs22.x" architecture = "x86_64" @@ -168,64 +164,87 @@ variables { path = "/lambda/" permissions_boundary = "arn:aws:iam::123456789012:policy/lambda-boundary" } - scale_up = { - memory_size = 768 - timeout = 40 - reserved_concurrent_executions = 2 - job_queued_check_enabled = false - event_source_mapping = { - batch_size = 5 - maximum_batching_window_in_seconds = 1 - } - tags = { Scope = "scale-up" } - } - scale_down = { - memory_size = 1024 - timeout = 90 - schedule_expression = "rate(10 minutes)" - minimum_running_time_in_minutes = 5 - idle_config = [{ - cron = "* * * * *" - timeZone = "Europe/Warsaw" - idleCount = 1 - evictionStrategy = "newest_first" - }] - tags = { Scope = "scale-down" } - } - pool = { - memory_size = 512 - timeout = 75 - reserved_concurrent_executions = 2 - config = [{ - schedule_expression = "cron(0 8 * * ? *)" - schedule_expression_timezone = "Europe/Warsaw" - size = 1 - }] - include_busy_runners = false - runner_owner = "cisco-open" - tags = { Scope = "pool" } - } - } - queue = { - delay_webhook_event = 7 - job_queue_retention_in_seconds = 90000 - visibility_timeout_seconds = 240 - redrive_build_queue = { - enabled = true - maxReceiveCount = 4 - } - tags = { Scope = "queue" } } - job_retry = { - enabled = true - delay_in_seconds = 120 - delay_backoff = 3 - max_attempts = 2 - tags = { Scope = "job-retry" } - lambda = { - memory_size = 512 - reserved_concurrent_executions = 3 - timeout = 45 + + orchestration = { + webhook = { + runner = { + maximum_count = 2 + } + github = { + organization_runners = true + } + lambda = { + scale_up = { + memory_size = 768 + timeout = 40 + reserved_concurrent_executions = 2 + job_queued_check_enabled = false + event_source_mapping = { + batch_size = 5 + maximum_batching_window_in_seconds = 1 + } + tags = { Scope = "scale-up" } + } + scale_down = { + memory_size = 1024 + timeout = 90 + schedule_expression = "rate(10 minutes)" + minimum_running_time_in_minutes = 5 + idle_config = [{ + cron = "* * * * *" + timeZone = "Europe/Warsaw" + idleCount = 1 + evictionStrategy = "newest_first" + }] + tags = { Scope = "scale-down" } + } + pool = { + memory_size = 512 + timeout = 75 + reserved_concurrent_executions = 2 + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + schedule_expression_timezone = "Europe/Warsaw" + size = 1 + }] + include_busy_runners = false + runner_owner = "cisco-open" + tags = { Scope = "pool" } + } + } + queue = { + delay_webhook_event = 7 + job_queue_retention_in_seconds = 90000 + visibility_timeout_seconds = 240 + redrive_build_queue = { + enabled = true + maxReceiveCount = 4 + } + tags = { Scope = "queue" } + } + job_retry = { + enabled = true + delay_in_seconds = 120 + delay_backoff = 3 + max_attempts = 2 + tags = { Scope = "job-retry" } + lambda = { + memory_size = 512 + reserved_concurrent_executions = 3 + timeout = 45 + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "ec2"], ["self-hosted", "gpu"]] + exactMatch = true + bidirectionalLabelMatch = true + priority = 5 + enableDynamicLabels = true + awsDynamicLabelsPolicy = { + blocked_keys = ["instance-type"] + } + } } } ssm = { @@ -243,6 +262,9 @@ variables { state = "DISABLED" tags = { Scope = "ssm-housekeeper" } lambda = { + artifact = { + zip = "/private/tmp/forge-test-lambda-cache/ssm-housekeeper.zip" + } memory_size = 384 timeout = 70 } @@ -325,7 +347,7 @@ variables { scale_errors = ["InsufficientInstanceCapacity"] ssm_enabled = true subnet_ids = ["subnet-override"] - tags = { Lane = "ec2" } + tags = { Configuration = "ec2" } user_data = { enabled = true pre_install = "caller-pre" @@ -345,22 +367,21 @@ variables { }] } } - matcherConfig = { - labelMatchers = [["self-hosted", "ec2"], ["self-hosted", "gpu"]] - exactMatch = true - bidirectionalLabelMatch = true - priority = 5 - enableDynamicLabels = true - awsDynamicLabelsPolicy = { - blocked_keys = ["instance-type"] - } - } } default_path = { runner = { - os = "linux" - architecture = "x64" - maximum_count = 1 + os = "linux" + architecture = "x64" + } + orchestration = { + webhook = { + runner = { + maximum_count = 1 + } + matcherConfig = { + labelMatchers = [["self-hosted", "default-path"]] + } + } } compute_provider = { ec2 = { @@ -371,9 +392,6 @@ variables { } } } - matcherConfig = { - labelMatchers = [["self-hosted", "default-path"]] - } } } } @@ -391,17 +409,28 @@ run "ec2_v2_input_plan" { assert { condition = toset(keys(local.ec2_runner_configs)) == toset(["default_path", "ec2"]) - error_message = "EC2 provider filtering must retain every EC2 lane." + error_message = "EC2 provider filtering must retain every EC2 runner configuration." } assert { condition = toset(keys(local.multi_runner_config)) == toset(["default_path", "ec2"]) - error_message = "The upstream v2 configuration must preserve every EC2 lane key." + error_message = "The upstream v2 configuration must preserve every EC2 runner-configuration key." + } + + assert { + condition = alltrue([ + for runner_config in values(local.multi_runner_config) : + length([ + for provider_config in values(runner_config.orchestration) : provider_config + if provider_config != null + ]) == 1 + ]) + error_message = "Every normalized runner configuration must retain exactly one orchestration provider." } assert { condition = local.active_ec2_subnet_ids == toset(["subnet-default", "subnet-override"]) - error_message = "EC2 effective subnet resolution must preserve per-lane overrides." + error_message = "EC2 effective subnet resolution must preserve per-configuration overrides." } assert { @@ -442,51 +471,51 @@ run "ec2_v2_input_plan" { && local.multi_runner_config.ec2.runner.name_prefix == "forge-" && local.multi_runner_config.ec2.runner.run_as_root && local.multi_runner_config.ec2.runner.run_as == "ec2-user" - && local.multi_runner_config.ec2.runner.maximum_count == 2 + && local.multi_runner_config.ec2.orchestration.webhook.runner.maximum_count == 2 && local.multi_runner_config.ec2.runner.ephemeral && !local.multi_runner_config.ec2.runner.jit_config_enabled && local.multi_runner_config.ec2.runner.auto_update_disabled - && local.multi_runner_config.ec2.github.organization_runners + && local.multi_runner_config.ec2.orchestration.webhook.github.organization_runners ) error_message = "The v2 configuration must preserve the complete nested runner and GitHub blocks." } assert { condition = ( - local.multi_runner_config.ec2.queue.delay_webhook_event == 7 - && local.multi_runner_config.ec2.queue.job_queue_retention_in_seconds == 90000 - && local.multi_runner_config.ec2.queue.visibility_timeout_seconds == 240 - && local.multi_runner_config.ec2.lambda.scale_up.event_source_mapping.batch_size == 5 - && local.multi_runner_config.ec2.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 1 - && local.multi_runner_config.ec2.lambda.scale_up.memory_size == 768 - && local.multi_runner_config.ec2.lambda.scale_up.timeout == 40 - && local.multi_runner_config.ec2.lambda.scale_up.reserved_concurrent_executions == 2 - && !local.multi_runner_config.ec2.lambda.scale_up.job_queued_check_enabled - && local.multi_runner_config.ec2.lambda.scale_down.memory_size == 1024 - && local.multi_runner_config.ec2.lambda.scale_down.timeout == 90 - && local.multi_runner_config.ec2.lambda.scale_down.schedule_expression == "rate(10 minutes)" - && local.multi_runner_config.ec2.lambda.scale_down.minimum_running_time_in_minutes == 5 - && local.multi_runner_config.ec2.lambda.scale_down.idle_config[0].idleCount == 1 - && local.multi_runner_config.ec2.queue.redrive_build_queue.maxReceiveCount == 4 + local.multi_runner_config.ec2.orchestration.webhook.queue.delay_webhook_event == 7 + && local.multi_runner_config.ec2.orchestration.webhook.queue.job_queue_retention_in_seconds == 90000 + && local.multi_runner_config.ec2.orchestration.webhook.queue.visibility_timeout_seconds == 240 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 5 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 1 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.memory_size == 768 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.timeout == 40 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.reserved_concurrent_executions == 2 + && !local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.job_queued_check_enabled + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.memory_size == 1024 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.timeout == 90 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.schedule_expression == "rate(10 minutes)" + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes == 5 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.idle_config[0].idleCount == 1 + && local.multi_runner_config.ec2.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount == 4 ) error_message = "The v2 configuration must preserve the queue, scale-up, and scale-down blocks." } assert { condition = ( - local.multi_runner_config.ec2.lambda.pool.config[0].size == 1 - && local.multi_runner_config.ec2.lambda.pool.memory_size == 512 - && local.multi_runner_config.ec2.lambda.pool.timeout == 75 - && local.multi_runner_config.ec2.lambda.pool.reserved_concurrent_executions == 2 - && !local.multi_runner_config.ec2.lambda.pool.include_busy_runners - && local.multi_runner_config.ec2.lambda.pool.runner_owner == "cisco-open" - && local.multi_runner_config.ec2.job_retry.enabled - && local.multi_runner_config.ec2.job_retry.delay_in_seconds == 120 - && local.multi_runner_config.ec2.job_retry.delay_backoff == 3 - && local.multi_runner_config.ec2.job_retry.max_attempts == 2 - && local.multi_runner_config.ec2.job_retry.lambda.memory_size == 512 - && local.multi_runner_config.ec2.job_retry.lambda.reserved_concurrent_executions == 3 - && local.multi_runner_config.ec2.job_retry.lambda.timeout == 45 + local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.config[0].size == 1 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.memory_size == 512 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.timeout == 75 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.reserved_concurrent_executions == 2 + && !local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.include_busy_runners + && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.runner_owner == "cisco-open" + && local.multi_runner_config.ec2.orchestration.webhook.job_retry.enabled + && local.multi_runner_config.ec2.orchestration.webhook.job_retry.delay_in_seconds == 120 + && local.multi_runner_config.ec2.orchestration.webhook.job_retry.delay_backoff == 3 + && local.multi_runner_config.ec2.orchestration.webhook.job_retry.max_attempts == 2 + && local.multi_runner_config.ec2.orchestration.webhook.job_retry.lambda.memory_size == 512 + && local.multi_runner_config.ec2.orchestration.webhook.job_retry.lambda.reserved_concurrent_executions == 3 + && local.multi_runner_config.ec2.orchestration.webhook.job_retry.lambda.timeout == 45 ) error_message = "The v2 configuration must preserve the pool and job-retry blocks." } @@ -501,7 +530,7 @@ run "ec2_v2_input_plan" { && length(local.multi_runner_config.ec2.compute_provider.ec2.log_files) == 4 && local.multi_runner_config.ec2.compute_provider.ec2.log_files[3].file_path == "/root/hook.log" && local.multi_runner_config.ec2.compute_provider.ec2.tags.Environment == "test" - && local.multi_runner_config.ec2.compute_provider.ec2.tags.Lane == "ec2" + && local.multi_runner_config.ec2.compute_provider.ec2.tags.Configuration == "ec2" ) error_message = "The v2 configuration must retain Forge user-data, logging, and tag overlays." } @@ -534,14 +563,14 @@ run "ec2_v2_input_plan" { assert { condition = ( - length(local.multi_runner_config.ec2.matcherConfig.labelMatchers) == 2 - && tolist(local.multi_runner_config.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) - && tolist(local.multi_runner_config.ec2.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) - && local.multi_runner_config.ec2.matcherConfig.exactMatch - && local.multi_runner_config.ec2.matcherConfig.bidirectionalLabelMatch - && local.multi_runner_config.ec2.matcherConfig.priority == 5 - && local.multi_runner_config.ec2.matcherConfig.enableDynamicLabels - && tolist(local.multi_runner_config.ec2.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) + length(local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.labelMatchers) == 2 + && tolist(local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) + && tolist(local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) + && local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.exactMatch + && local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.bidirectionalLabelMatch + && local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.priority == 5 + && local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.enableDynamicLabels + && tolist(local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) ) error_message = "The v2 configuration must preserve matcher configuration." } @@ -551,11 +580,11 @@ run "ec2_v2_input_plan" { local.multi_runner_config.ec2.tags.Scope == "entry" && local.multi_runner_config.ec2.runner.tags.Scope == "runner" && local.multi_runner_config.ec2.lambda.tags.Scope == "lambda" - && local.multi_runner_config.ec2.queue.tags.Scope == "queue" - && local.multi_runner_config.ec2.lambda.scale_up.tags.Scope == "scale-up" - && local.multi_runner_config.ec2.lambda.scale_down.tags.Scope == "scale-down" - && local.multi_runner_config.ec2.lambda.pool.tags.Scope == "pool" - && local.multi_runner_config.ec2.job_retry.tags.Scope == "job-retry" + && local.multi_runner_config.ec2.orchestration.webhook.queue.tags.Scope == "queue" + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.tags.Scope == "scale-up" + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.tags.Scope == "scale-down" + && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.tags.Scope == "pool" + && local.multi_runner_config.ec2.orchestration.webhook.job_retry.tags.Scope == "job-retry" && local.multi_runner_config.ec2.ssm.tags.Scope == "ssm" && local.multi_runner_config.ec2.ssm.parameters.tags.Scope == "ssm-parameters" && local.multi_runner_config.ec2.ssm.housekeeper.tags.Scope == "ssm-housekeeper" @@ -570,7 +599,7 @@ run "ec2_v2_input_plan" { && local.multi_runner_config.ec2.runner.iam.path == "/forge/" && local.multi_runner_config.ec2.runner.iam.permissions_boundary == "arn:aws:iam::123456789012:policy/boundary" ) - error_message = "The v2 configuration must preserve lane IAM ownership settings." + error_message = "The v2 configuration must preserve runner-configuration IAM ownership settings." } assert { @@ -582,7 +611,7 @@ run "ec2_v2_input_plan" { && local.multi_runner_config.ec2.lambda.role.path == "/lambda/" && local.multi_runner_config.ec2.lambda.role.permissions_boundary == "arn:aws:iam::123456789012:policy/lambda-boundary" ) - error_message = "The v2 configuration must preserve lane Lambda runtime, network, and role overrides." + error_message = "The v2 configuration must preserve runner-configuration Lambda runtime, network, and role overrides." } assert { @@ -592,13 +621,14 @@ run "ec2_v2_input_plan" { && local.multi_runner_config.ec2.ssm.paths.config == "custom-config" && local.multi_runner_config.ec2.ssm.housekeeper.schedule_expression == "rate(12 hours)" && local.multi_runner_config.ec2.ssm.housekeeper.state == "DISABLED" + && local.multi_runner_config.ec2.ssm.housekeeper.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/ssm-housekeeper.zip" && local.multi_runner_config.ec2.ssm.housekeeper.lambda.memory_size == 384 && local.multi_runner_config.ec2.ssm.housekeeper.lambda.timeout == 70 && local.multi_runner_config.ec2.ssm.housekeeper.config.tokenPath == "custom-token-path" && local.multi_runner_config.ec2.ssm.housekeeper.config.minimumDaysOld == 2 && local.multi_runner_config.ec2.ssm.housekeeper.config.dryRun ) - error_message = "The v2 configuration must preserve lane SSM paths and housekeeper overrides." + error_message = "The v2 configuration must preserve runner-configuration SSM paths and housekeeper overrides." } assert { @@ -615,19 +645,20 @@ run "ec2_v2_input_plan" { && !local.multi_runner_config.ec2.observability.metrics.metric.enable_github_app_rate_limit && local.multi_runner_config.ec2.observability.metrics.metric.enable_job_retry ) - error_message = "The v2 configuration must preserve lane observability overrides." + error_message = "The v2 configuration must preserve runner-configuration observability overrides." } assert { condition = ( local.experimental_config.github.app.id == "12345" - && local.experimental_config.enterprise_server.url == null - && local.experimental_config.webhook.eventbridge.enable - && local.experimental_config.lambda.scale.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" + && local.experimental_config.github.enterprise_server.url == null + && local.experimental_config.orchestration.webhook.eventbridge.enable + && local.experimental_config.orchestration.webhook.lambda.scale.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" && tolist(local.experimental_config.lambda.subnet_ids) == tolist(["subnet-test"]) && length(local.experimental_config.lambda.security_group_ids) == 1 - && local.experimental_config.lambda.webhook.artifact.zip == "/private/tmp/forge-test-lambda-cache/webhook.zip" + && local.experimental_config.orchestration.webhook.lambda.webhook.artifact.zip == "/private/tmp/forge-test-lambda-cache/webhook.zip" && local.experimental_config.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/00000000-0000-0000-0000-000000000000" + && local.experimental_config.ssm.housekeeper.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" && local.experimental_config.observability.logs.level == "info" && local.experimental_config.observability.logs.retention_in_days == 3 && local.experimental_config.compute_provider.ec2.vpc_id == "vpc-test" @@ -649,8 +680,8 @@ run "ec2_v2_input_plan" { assert { condition = ( - local.experimental_config.lambda.webhook.api_gateway_access_log_settings.destination_arn == aws_cloudwatch_log_group.webhook_api_gateway_access.arn - && local.experimental_config.lambda.webhook.api_gateway_access_log_settings.format == local.webhook_api_gateway_access_log_format + local.experimental_config.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn == aws_cloudwatch_log_group.webhook_api_gateway_access.arn + && local.experimental_config.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.format == local.webhook_api_gateway_access_log_format ) error_message = "Forge webhook API Gateway access-log settings must be carried by the experimental webhook contract." } diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index a29a5703..b6d04de8 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -54,7 +54,9 @@ run "platform_ec2_deployment_interface_contract" { "name_prefix = optional(string, null)", "run_as_root = optional(bool, null)", "run_as = optional(string, null)", - "maximum_count = number", + "orchestration = object({", + "webhook = optional(object({", + "maximum_count = number", "ephemeral = optional(bool, null)", "jit_config_enabled = optional(bool, null)", "auto_update_disabled = optional(bool, null)", @@ -118,6 +120,9 @@ run "platform_ec2_deployment_interface_contract" { "config = optional(string, null)", "parameters = optional(object({", "housekeeper = optional(object({", + "artifact = optional(object({", + "zip = optional(string, null)", + "object_version = optional(string, null)", "state = optional(string, null)", "tokenPath = optional(string, null)", "minimumDaysOld = optional(number, null)", @@ -227,6 +232,9 @@ run "platform_ec2_deployment_interface_contract" { "enableDynamicLabels = optional(bool, false)", "awsDynamicLabelsPolicy = optional(object({", "for runner_config in values(var.runner_configs.runner_specs) :", + "for provider_config in values(runner_config.orchestration) : provider_config", + "if provider_config != null", + "error_message = \"Each Forge runner configuration must select exactly one non-null orchestration provider.\"", "variable \"tenant_configs\"", "ecr_registries = list(string)", "tags = map(string)", @@ -244,7 +252,8 @@ run "platform_ec2_deployment_interface_contract" { "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.\"", "active_ec2_subnet_ids = toset(flatten([", "legacy_runner_labels = {", - "matcher_index, labels in runner_config.matcherConfig.labelMatchers", + "runner_config.orchestration.webhook.matcherConfig.labelMatchers[0]", + "matcher_index, labels in runner_config.orchestration.webhook.matcherConfig.labelMatchers", "!contains(local.legacy_runner_labels[key], label)", "ec2_default_ami_filters = {", "windows = { name = [\"Windows_Server-2022-English-Full-ECS_Optimized-*\"] }", @@ -279,6 +288,8 @@ run "platform_ec2_deployment_interface_contract" { "experimental = local.experimental_config", "experimental_config = {", "app = var.runner_configs.github_app", + "enterprise_server = {", + "orchestration = {", "url = try(trimspace(var.runner_configs.ghes_url), \"\") == \"\" ? null : var.runner_configs.ghes_url", "artifact = {", "kms_key_id = aws_kms_key.github.arn", @@ -330,7 +341,7 @@ run "platform_ec2_deployment_interface_contract" { condition = ( output.expected_input_variable_count == 4 && output.expected_output_value_count == 6 - && output.expected_interface_literal_count == 276 + && output.expected_interface_literal_count == 287 ) 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 e76e4890..fbd99eed 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -16,6 +16,11 @@ run "platform_ec2_deployment_contract" { "experimental = local.experimental_config", "experimental_config = {", "app = var.runner_configs.github_app", + "enterprise_server = {", + "orchestration = {", + "maximum_count = number", + "runner_config.orchestration.webhook.matcherConfig.labelMatchers[0]", + "matcher_index, labels in runner_config.orchestration.webhook.matcherConfig.labelMatchers", "kms_key_id = aws_kms_key.github.arn", "multi_runner_config = local.multi_runner_config", "resource \"aws_cloudwatch_log_group\" \"webhook_api_gateway_access\"", diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index eabcc3b6..1184e2f0 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -29,7 +29,6 @@ variable "runner_configs" { name_prefix = optional(string, null) run_as_root = optional(bool, null) run_as = optional(string, null) - maximum_count = number ephemeral = optional(bool, null) jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, null) @@ -49,10 +48,6 @@ variable "runner_configs" { }), {}) }) - github = optional(object({ - organization_runners = optional(bool, false) - }), {}) - lambda = optional(object({ runtime = optional(string, null) architecture = optional(string, null) @@ -63,68 +58,99 @@ variable "runner_configs" { path = optional(string, null) permissions_boundary = optional(string, null) }), {}) - scale_up = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - reserved_concurrent_executions = optional(number, null) - job_queued_check_enabled = optional(bool, null) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) - }), {}) - tags = optional(map(string), {}) - }), {}) - scale_down = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - schedule_expression = optional(string, null) - minimum_running_time_in_minutes = optional(number, null) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), null) - tags = optional(map(string), {}) - }), {}) - pool = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - reserved_concurrent_executions = optional(number, null) - config = optional(list(object({ - schedule_expression = string - schedule_expression_timezone = optional(string) - size = number - })), null) - include_busy_runners = optional(bool, null) - runner_owner = optional(string, null) - tags = optional(map(string), {}) - }), {}) }), {}) - queue = optional(object({ - delay_webhook_event = optional(number, null) - job_queue_retention_in_seconds = optional(number, null) - visibility_timeout_seconds = optional(number, null) - redrive_build_queue = optional(object({ - enabled = optional(bool, null) - maxReceiveCount = optional(number, null) - }), null) - tags = optional(map(string), {}) - }), {}) + orchestration = object({ + webhook = optional(object({ + runner = object({ + maximum_count = number + }) - job_retry = optional(object({ - enabled = optional(bool, false) - delay_in_seconds = optional(number, 300) - delay_backoff = optional(number, 2) - max_attempts = optional(number, 1) - tags = optional(map(string), {}) - lambda = optional(object({ - memory_size = optional(number, 256) - reserved_concurrent_executions = optional(number, 1) - timeout = optional(number, 30) - }), {}) - }), {}) + github = optional(object({ + organization_runners = optional(bool, false) + }), {}) + + matcherConfig = object({ + labelMatchers = list(list(string)) + exactMatch = optional(bool, false) + bidirectionalLabelMatch = optional(bool, false) + priority = optional(number, 999) + enableDynamicLabels = optional(bool, false) + awsDynamicLabelsPolicy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) + }) + + queue = optional(object({ + delay_webhook_event = optional(number, null) + job_queue_retention_in_seconds = optional(number, null) + visibility_timeout_seconds = optional(number, null) + redrive_build_queue = optional(object({ + enabled = optional(bool, null) + maxReceiveCount = optional(number, null) + }), null) + tags = optional(map(string), {}) + }), {}) + + lambda = optional(object({ + scale_up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + scale_down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) + }), {}) + pool = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + config = optional(list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })), null) + include_busy_runners = optional(bool, null) + runner_owner = optional(string, null) + tags = optional(map(string), {}) + }), {}) + }), {}) + + job_retry = optional(object({ + enabled = optional(bool, false) + delay_in_seconds = optional(number, 300) + delay_backoff = optional(number, 2) + max_attempts = optional(number, 1) + tags = optional(map(string), {}) + lambda = optional(object({ + memory_size = optional(number, 256) + reserved_concurrent_executions = optional(number, 1) + timeout = optional(number, 30) + }), {}) + }), {}) + }), null) + }) ssm = optional(object({ paths = optional(object({ @@ -141,6 +167,13 @@ variable "runner_configs" { state = optional(string, null) tags = optional(map(string), {}) lambda = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) memory_size = optional(number, null) timeout = optional(number, null) }), {}) @@ -297,24 +330,20 @@ variable "runner_configs" { }), null) }) - matcherConfig = object({ - labelMatchers = list(list(string)) - exactMatch = optional(bool, false) - bidirectionalLabelMatch = optional(bool, false) - priority = optional(number, 999) - enableDynamicLabels = optional(bool, false) - awsDynamicLabelsPolicy = optional(object({ - blocked_keys = optional(list(string), []) - restricted_keys = optional(map(object({ - allowed = optional(list(string), []) - denied = optional(list(string), []) - max = optional(string, null) - })), {}) - }), null) - }) })) }) + validation { + condition = alltrue([ + for runner_config in values(var.runner_configs.runner_specs) : + length([ + for provider_config in values(runner_config.orchestration) : provider_config + if provider_config != null + ]) == 1 + ]) + error_message = "Each Forge runner configuration must select exactly one non-null orchestration provider." + } + 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 e7d00974..b2bf2fb9 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -18,19 +18,22 @@ 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 and ARC lanes can be enabled for the same tenant; workflows choose by labels. +- EC2 and ARC runner configurations can be enabled for the same tenant; workflows choose by labels. - `ec2_deployment_specs.runner_specs` uses the full nested v2 shape; every entry - must configure `runner`, `matcherConfig`, and `compute_provider.ec2`. + must configure `runner`, `orchestration.webhook`, and `compute_provider.ec2`. - `ec2_deployment` enriches that public shape with Forge-managed settings and passes it through upstream's `experimental.multi_runner_config`; shared - GitHub App, Lambda artifact/network, SSM KMS, observability, and EC2 network - settings are populated in the experimental global blocks. -- Per-lane scale-up, scale-down, and pool settings are nested under `lambda`; - event-source batching is under `lambda.scale_up`, while queue visibility is - configured under `queue`. -- The v2 runner graph changes internal Terraform addresses and has no upstream - `moved` blocks. Use this ref for new state only until a state migration and - exact-ref Lambda artifacts have been validated for existing deployments. + GitHub API, common Lambda substrate, SSM KMS and housekeeper artifact, + observability, and EC2 network settings are populated in the experimental + global blocks. +- Webhook capacity, GitHub scope, matching, queue, scale-up, scale-down, pool, + and retry settings are nested below `orchestration.webhook`; the common + per-configuration `lambda` block remains orchestration-provider-neutral. +- The v2 runner graph changes internal Terraform addresses. Upstream moves the + prior experimental `module.runner_stacks` address to + `module.runner_configs`; migration from the stable v1 graph and exact-ref + Lambda artifact compatibility still require separate validation for existing + deployments. - Changing GitHub App or runner-group settings can affect job routing immediately. ## Deployment version inventory @@ -74,10 +77,10 @@ tag with the expected ref in `release_versions.yml`. | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.59.0 | -| [null](#provider\_null) | 3.3.0 | +| [aws](#provider\_aws) | 6.60.0 | +| [null](#provider\_null) | 3.3.1 | | [random](#provider\_random) | 3.9.0 | -| [time](#provider\_time) | 0.14.0 | +| [time](#provider\_time) | 0.14.1 | ## Modules @@ -126,7 +129,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. The public runner
shape follows the nested experimental multi\_runner\_config EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 : Provider-neutral OS, architecture, labels, registration,
hooks, capacity, and IAM-policy configuration.
- github : Organization-versus-repository runner registration.
- lambda : Per-lane Lambda defaults, with scale\_up, scale\_down, and
pool component configuration nested below it.
- queue : Webhook delay, retention, visibility timeout, and DLQ
redrive configuration.
- job\_retry : Retry timing, attempts, and Lambda sizing.
- ssm : Per-lane paths, tags, parameter tags, and housekeeper.
- observability : Per-lane logging, tracing, and metrics overrides.
- matcherConfig : Static and dynamic GitHub label matching.
- compute\_provider: Nested v2 EC2 provider configuration.
- tags : Per-lane resource tags.

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/security-group fields: Per-lane networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
maximum_count = number
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | EC2 deployment configuration for GitHub Actions runners. The public runner
shape follows the nested experimental multi\_runner\_config EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 configurations.

runner\_specs[*] object fields:
- runner : Provider-neutral OS, architecture, labels, registration,
hooks, and IAM-policy configuration.
- lambda : Provider-neutral per-configuration Lambda runtime,
network, role, and tag overrides.
- orchestration : Exactly one demand-controller provider. The webhook
provider owns runner capacity, GitHub scope, matching,
queues, scaling, pools, and job retry.
- ssm : Per-configuration paths, tags, parameter tags, and
housekeeper settings, including its Lambda artifact.
- observability : Per-configuration logging, tracing, and metrics overrides.
- compute\_provider: Nested v2 EC2 provider configuration.
- tags : Per-configuration resource tags.

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/security-group fields: Per-configuration networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = object({
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| 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 23dbceb0..ce86095d 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -150,7 +150,9 @@ run "platform_forge_runners_interface_contract" { "name_prefix = optional(string, null)", "run_as_root = optional(bool, null)", "run_as = optional(string, null)", - "maximum_count = number", + "orchestration = object({", + "webhook = optional(object({", + "maximum_count = number", "ephemeral = optional(bool, null)", "jit_config_enabled = optional(bool, null)", "auto_update_disabled = optional(bool, null)", @@ -214,6 +216,9 @@ run "platform_forge_runners_interface_contract" { "config = optional(string, null)", "parameters = optional(object({", "housekeeper = optional(object({", + "artifact = optional(object({", + "zip = optional(string, null)", + "object_version = optional(string, null)", "state = optional(string, null)", "tokenPath = optional(string, null)", "minimumDaysOld = optional(number, null)", @@ -323,6 +328,9 @@ run "platform_forge_runners_interface_contract" { "enableDynamicLabels = optional(bool, false)", "awsDynamicLabelsPolicy = optional(object({", "for runner_config in values(var.ec2_deployment_specs.runner_specs) :", + "for provider_config in values(runner_config.orchestration) : provider_config", + "if provider_config != null", + "error_message = \"Each Forge runner configuration must select exactly one non-null orchestration provider.\"", "length(runner_config.compute_provider.ec2[*]) == 1", "length(runner_config.compute_provider.ec2.ami[*]) == 1", "&& length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0,", @@ -332,19 +340,21 @@ run "platform_forge_runners_interface_contract" { "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 : Map of EC2 runner configurations.", "- runner : Provider-neutral OS, architecture, labels, registration,", - "- job_retry : Retry timing, attempts, and Lambda sizing.", - "- matcherConfig : Static and dynamic GitHub label matching.", + "hooks, and IAM-policy configuration.", "shape follows the nested experimental multi_runner_config EC2 contract and", "is passed directly to the upstream provider-oriented runner stack.", - "- lambda : Per-lane Lambda defaults, with scale_up, scale_down, and", - "pool component configuration nested below it.", - "- queue : Webhook delay, retention, visibility timeout, and DLQ", - "- ssm : Per-lane paths, tags, parameter tags, and housekeeper.", - "- observability : Per-lane logging, tracing, and metrics overrides.", + "- lambda : Provider-neutral per-configuration Lambda runtime,", + "network, role, and tag overrides.", + "- orchestration : Exactly one demand-controller provider. The webhook", + "provider owns runner capacity, GitHub scope, matching,", + "queues, scaling, pools, and job retry.", + "- ssm : Per-configuration paths, tags, parameter tags, and", + "housekeeper settings, including its Lambda artifact.", + "- observability : Per-configuration logging, tracing, and metrics overrides.", "- compute_provider: Nested v2 EC2 provider configuration.", - "- tags : Per-lane resource tags.", + "- tags : Per-configuration resource tags.", "compute_provider.ec2 fields:", "- ami : Upstream-compatible EC2 AMI configuration.", "- instance_profile: Optional externally managed instance profile. It requires", @@ -435,7 +445,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 == 376 + && output.expected_interface_literal_count == 386 ) 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 0002297a..cebb15a7 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -19,6 +19,9 @@ run "platform_forge_runners_contract" { "resource \"random_id\" \"random\"", "runner_specs = var.ec2_deployment_specs.runner_specs", "runner = object({", + "orchestration = object({", + "webhook = optional(object({", + "maximum_count = number", "lambda = optional(object({", "queue = optional(object({", "visibility_timeout_seconds = optional(number, null)", @@ -26,6 +29,7 @@ run "platform_forge_runners_contract" { "scale_down = optional(object({", "pool = optional(object({", "job_retry = optional(object({", + "artifact = optional(object({", "compute_provider = object({", "ec2 = optional(object({", "matcherConfig = object({", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index c3ccd832..b337df6d 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -27,7 +27,6 @@ variable "ec2_deployment_specs" { name_prefix = optional(string, null) run_as_root = optional(bool, null) run_as = optional(string, null) - maximum_count = number ephemeral = optional(bool, null) jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, null) @@ -47,10 +46,6 @@ variable "ec2_deployment_specs" { }), {}) }) - github = optional(object({ - organization_runners = optional(bool, false) - }), {}) - lambda = optional(object({ runtime = optional(string, null) architecture = optional(string, null) @@ -61,68 +56,99 @@ variable "ec2_deployment_specs" { path = optional(string, null) permissions_boundary = optional(string, null) }), {}) - scale_up = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - reserved_concurrent_executions = optional(number, null) - job_queued_check_enabled = optional(bool, null) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) - }), {}) - tags = optional(map(string), {}) - }), {}) - scale_down = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - schedule_expression = optional(string, null) - minimum_running_time_in_minutes = optional(number, null) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), null) - tags = optional(map(string), {}) - }), {}) - pool = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - reserved_concurrent_executions = optional(number, null) - config = optional(list(object({ - schedule_expression = string - schedule_expression_timezone = optional(string) - size = number - })), null) - include_busy_runners = optional(bool, null) - runner_owner = optional(string, null) - tags = optional(map(string), {}) - }), {}) }), {}) - queue = optional(object({ - delay_webhook_event = optional(number, null) - job_queue_retention_in_seconds = optional(number, null) - visibility_timeout_seconds = optional(number, null) - redrive_build_queue = optional(object({ - enabled = optional(bool, null) - maxReceiveCount = optional(number, null) - }), null) - tags = optional(map(string), {}) - }), {}) + orchestration = object({ + webhook = optional(object({ + runner = object({ + maximum_count = number + }) - job_retry = optional(object({ - enabled = optional(bool, false) - delay_in_seconds = optional(number, 300) - delay_backoff = optional(number, 2) - max_attempts = optional(number, 1) - tags = optional(map(string), {}) - lambda = optional(object({ - memory_size = optional(number, 256) - reserved_concurrent_executions = optional(number, 1) - timeout = optional(number, 30) - }), {}) - }), {}) + github = optional(object({ + organization_runners = optional(bool, false) + }), {}) + + matcherConfig = object({ + labelMatchers = list(list(string)) + exactMatch = optional(bool, false) + bidirectionalLabelMatch = optional(bool, false) + priority = optional(number, 999) + enableDynamicLabels = optional(bool, false) + awsDynamicLabelsPolicy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) + }) + + queue = optional(object({ + delay_webhook_event = optional(number, null) + job_queue_retention_in_seconds = optional(number, null) + visibility_timeout_seconds = optional(number, null) + redrive_build_queue = optional(object({ + enabled = optional(bool, null) + maxReceiveCount = optional(number, null) + }), null) + tags = optional(map(string), {}) + }), {}) + + lambda = optional(object({ + scale_up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + scale_down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) + }), {}) + pool = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + config = optional(list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })), null) + include_busy_runners = optional(bool, null) + runner_owner = optional(string, null) + tags = optional(map(string), {}) + }), {}) + }), {}) + + job_retry = optional(object({ + enabled = optional(bool, false) + delay_in_seconds = optional(number, 300) + delay_backoff = optional(number, 2) + max_attempts = optional(number, 1) + tags = optional(map(string), {}) + lambda = optional(object({ + memory_size = optional(number, 256) + reserved_concurrent_executions = optional(number, 1) + timeout = optional(number, 30) + }), {}) + }), {}) + }), null) + }) ssm = optional(object({ paths = optional(object({ @@ -139,6 +165,13 @@ variable "ec2_deployment_specs" { state = optional(string, null) tags = optional(map(string), {}) lambda = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) memory_size = optional(number, null) timeout = optional(number, null) }), {}) @@ -295,24 +328,20 @@ variable "ec2_deployment_specs" { }), null) }) - matcherConfig = object({ - labelMatchers = list(list(string)) - exactMatch = optional(bool, false) - bidirectionalLabelMatch = optional(bool, false) - priority = optional(number, 999) - enableDynamicLabels = optional(bool, false) - awsDynamicLabelsPolicy = optional(object({ - blocked_keys = optional(list(string), []) - restricted_keys = optional(map(object({ - allowed = optional(list(string), []) - denied = optional(list(string), []) - max = optional(string, null) - })), {}) - }), null) - }) })) }) + validation { + condition = alltrue([ + for runner_config in values(var.ec2_deployment_specs.runner_specs) : + length([ + for provider_config in values(runner_config.orchestration) : provider_config + if provider_config != null + ]) == 1 + ]) + error_message = "Each Forge runner configuration must select exactly one non-null orchestration provider." + } + validation { condition = alltrue([ for runner_config in values(var.ec2_deployment_specs.runner_specs) : @@ -336,22 +365,21 @@ variable "ec2_deployment_specs" { 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 : Map of EC2 runner configurations. runner_specs[*] object fields: - runner : Provider-neutral OS, architecture, labels, registration, - hooks, capacity, and IAM-policy configuration. - - github : Organization-versus-repository runner registration. - - lambda : Per-lane Lambda defaults, with scale_up, scale_down, and - pool component configuration nested below it. - - queue : Webhook delay, retention, visibility timeout, and DLQ - redrive configuration. - - job_retry : Retry timing, attempts, and Lambda sizing. - - ssm : Per-lane paths, tags, parameter tags, and housekeeper. - - observability : Per-lane logging, tracing, and metrics overrides. - - matcherConfig : Static and dynamic GitHub label matching. + hooks, and IAM-policy configuration. + - lambda : Provider-neutral per-configuration Lambda runtime, + network, role, and tag overrides. + - orchestration : Exactly one demand-controller provider. The webhook + provider owns runner capacity, GitHub scope, matching, + queues, scaling, pools, and job retry. + - ssm : Per-configuration paths, tags, parameter tags, and + housekeeper settings, including its Lambda artifact. + - observability : Per-configuration logging, tracing, and metrics overrides. - compute_provider: Nested v2 EC2 provider configuration. - - tags : Per-lane resource tags. + - tags : Per-configuration resource tags. compute_provider.ec2 fields: - ami : Upstream-compatible EC2 AMI configuration. @@ -361,7 +389,7 @@ variable "ec2_deployment_specs" { - 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/security-group fields: Per-lane networking. + - vpc_id/subnet_ids/security-group fields: Per-configuration networking. - cpu_options/placement/license_specifications: EC2 launch-template options. - instance_profile: Optional externally managed instance profile. It requires an externally managed runner IAM role whose owner also From e521d36d3f739ceef5c1f8aceb1a48288d9c87a0 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 18:54:35 +0200 Subject: [PATCH 5/8] refactor(runners): align webhook-owned settings --- .../main/tenants/acme/runner_settings.hcl | 20 ++-- .../platform/tenant/runner_settings.hcl | 20 ++-- modules/platform/ec2_deployment/README.md | 26 ++--- .../ec2_deployment/runner_configs_v2.tf | 6 +- .../ec2_deployment/tests/behavior.tftest.hcl | 97 ++++++++++--------- .../tests/interface_contract.tftest.hcl | 17 ++-- .../tests/source_inventory.tftest.hcl | 8 +- modules/platform/ec2_deployment/variables.tf | 54 ++++++----- modules/platform/forge_runners/README.md | 12 ++- .../tests/interface_contract.tftest.hcl | 24 ++--- .../tests/source_inventory.tftest.hcl | 10 +- modules/platform/forge_runners/variables.tf | 61 ++++++------ 12 files changed, 195 insertions(+), 160 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 a9d8bf07..8a29555d 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 @@ -75,26 +75,28 @@ locals { ] group_name = local.runner_group_name run_as = spec.runner_user - ephemeral = true } orchestration = { webhook = { runner = { maximum_count = spec.max_instances + ephemeral = true } github = { organization_runners = true } lambda = { - scale_up = { - job_queued_check_enabled = false - event_source_mapping = { - batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + scale = { + up = { + job_queued_check_enabled = false + event_source_mapping = { + batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + } + } + down = { + minimum_running_time_in_minutes = 30 } - } - scale_down = { - minimum_running_time_in_minutes = 30 } pool = { config = spec.pool_config diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index a9d8bf07..8a29555d 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -75,26 +75,28 @@ locals { ] group_name = local.runner_group_name run_as = spec.runner_user - ephemeral = true } orchestration = { webhook = { runner = { maximum_count = spec.max_instances + ephemeral = true } github = { organization_runners = true } lambda = { - scale_up = { - job_queued_check_enabled = false - event_source_mapping = { - batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + scale = { + up = { + job_queued_check_enabled = false + event_source_mapping = { + batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + } + } + down = { + minimum_running_time_in_minutes = 30 } - } - scale_down = { - minimum_running_time_in_minutes = 30 } pool = { config = spec.pool_config diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index d1aed69a..95f1347d 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -7,12 +7,12 @@ This module deploys Forge EC2 runner pools through the upstream The experimental input lets each runner configuration select a typed orchestration provider and a typed compute provider. Webhook orchestration owns -capacity, GitHub scope, label matching, queues, scale-up, scale-down, pool, and -retry settings. The common Lambda block remains provider-neutral and owns only -runtime, architecture, networking, role, and tag overrides. Forge enriches each -configuration with its required hooks, IAM policies, bootstrap content, logging, -and tags, then passes the canonical map through -`experimental.multi_runner_config`. +runner lifecycle, capacity, startup timing, GitHub scope, label matching, +queues, scale-up, scale-down, pool, and retry settings. The common Lambda block +remains provider-neutral and owns only runtime, architecture, networking, role, +and tag overrides. Forge enriches each configuration with its required hooks, IAM +policies, bootstrap content, logging, and tags, then passes the canonical map +through `experimental.multi_runner_config`. Global experimental settings own the GitHub App and API client, common Lambda substrate, webhook orchestration defaults and artifacts, the SSM housekeeper @@ -30,10 +30,14 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. - This is a breaking input migration: every `runner_specs` entry uses the full nested v2 shape and must contain `runner`, `orchestration.webhook`, and - `compute_provider.ec2`. `orchestration.webhook.runner.maximum_count` is the - webhook capacity limit. Matching, queue, scale-up, scale-down, pool, and retry - settings stay inside that provider block. The legacy flat EC2 shape is not - accepted. + `compute_provider.ec2`. The `orchestration.webhook.runner` block owns the + `ephemeral` and `jit_config_enabled` lifecycle modes, `maximum_count` capacity + limit, and `boot_time_in_minutes` startup estimate. Matching, queue, + `lambda.scale.up`, `lambda.scale.down`, pool, and retry settings stay inside + that provider block. The legacy flat EC2 shape is not accepted. +- The runner-control archive is selected once at + `orchestration.webhook.lambda.artifact`. Per-runner webhook Lambda blocks own + only scale and pool runtime settings and cannot select an artifact. - The SSM housekeeper has its own artifact selector under `ssm.housekeeper.lambda.artifact`; Forge points the global selector at the runner control-plane archive. @@ -110,7 +114,7 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. | ---- | ----------- | ---- | ------- | :------: | | [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
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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = object({
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| n/a | yes | +| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = 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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| 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/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf index df8fe17d..0fa081df 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -240,10 +240,8 @@ locals { enable = true } lambda = { - scale = { - artifact = { - zip = "${data.external.download_lambdas.result.path}/runners.zip" - } + artifact = { + zip = "${data.external.download_lambdas.result.path}/runners.zip" } webhook = { artifact = { diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index 8758587a..c42119fe 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -130,15 +130,12 @@ variables { runner = { os = "linux" architecture = "x64" - boot_time_in_minutes = 7 disable_default_labels = true extra_labels = ["caller-extra"] group_name = "Forge" name_prefix = "forge-" run_as_root = true run_as = "ec2-user" - ephemeral = true - jit_config_enabled = false auto_update_disabled = true tags = { Scope = "runner" } hooks = { @@ -169,35 +166,40 @@ variables { orchestration = { webhook = { runner = { - maximum_count = 2 + boot_time_in_minutes = 7 + ephemeral = true + jit_config_enabled = false + maximum_count = 2 } github = { organization_runners = true } lambda = { - scale_up = { - memory_size = 768 - timeout = 40 - reserved_concurrent_executions = 2 - job_queued_check_enabled = false - event_source_mapping = { - batch_size = 5 - maximum_batching_window_in_seconds = 1 + scale = { + up = { + memory_size = 768 + timeout = 40 + reserved_concurrent_executions = 2 + job_queued_check_enabled = false + event_source_mapping = { + batch_size = 5 + maximum_batching_window_in_seconds = 1 + } + tags = { Scope = "scale-up" } + } + down = { + memory_size = 1024 + timeout = 90 + schedule_expression = "rate(10 minutes)" + minimum_running_time_in_minutes = 5 + idle_config = [{ + cron = "* * * * *" + timeZone = "Europe/Warsaw" + idleCount = 1 + evictionStrategy = "newest_first" + }] + tags = { Scope = "scale-down" } } - tags = { Scope = "scale-up" } - } - scale_down = { - memory_size = 1024 - timeout = 90 - schedule_expression = "rate(10 minutes)" - minimum_running_time_in_minutes = 5 - idle_config = [{ - cron = "* * * * *" - timeZone = "Europe/Warsaw" - idleCount = 1 - evictionStrategy = "newest_first" - }] - tags = { Scope = "scale-down" } } pool = { memory_size = 512 @@ -464,20 +466,20 @@ run "ec2_v2_input_plan" { assert { condition = ( - local.multi_runner_config.ec2.runner.boot_time_in_minutes == 7 - && local.multi_runner_config.ec2.runner.disable_default_labels + local.multi_runner_config.ec2.runner.disable_default_labels && tolist(local.multi_runner_config.ec2.runner.extra_labels) == tolist(["caller-extra"]) && local.multi_runner_config.ec2.runner.group_name == "Forge" && local.multi_runner_config.ec2.runner.name_prefix == "forge-" && local.multi_runner_config.ec2.runner.run_as_root && local.multi_runner_config.ec2.runner.run_as == "ec2-user" + && local.multi_runner_config.ec2.orchestration.webhook.runner.boot_time_in_minutes == 7 + && local.multi_runner_config.ec2.orchestration.webhook.runner.ephemeral + && !local.multi_runner_config.ec2.orchestration.webhook.runner.jit_config_enabled && local.multi_runner_config.ec2.orchestration.webhook.runner.maximum_count == 2 - && local.multi_runner_config.ec2.runner.ephemeral - && !local.multi_runner_config.ec2.runner.jit_config_enabled && local.multi_runner_config.ec2.runner.auto_update_disabled && local.multi_runner_config.ec2.orchestration.webhook.github.organization_runners ) - error_message = "The v2 configuration must preserve the complete nested runner and GitHub blocks." + error_message = "The v2 configuration must preserve the common runner and webhook-owned runner and GitHub blocks." } assert { @@ -485,17 +487,17 @@ run "ec2_v2_input_plan" { local.multi_runner_config.ec2.orchestration.webhook.queue.delay_webhook_event == 7 && local.multi_runner_config.ec2.orchestration.webhook.queue.job_queue_retention_in_seconds == 90000 && local.multi_runner_config.ec2.orchestration.webhook.queue.visibility_timeout_seconds == 240 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.event_source_mapping.batch_size == 5 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.event_source_mapping.maximum_batching_window_in_seconds == 1 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.memory_size == 768 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.timeout == 40 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.reserved_concurrent_executions == 2 - && !local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.job_queued_check_enabled - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.memory_size == 1024 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.timeout == 90 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.schedule_expression == "rate(10 minutes)" - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.minimum_running_time_in_minutes == 5 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.idle_config[0].idleCount == 1 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 5 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 1 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.memory_size == 768 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.timeout == 40 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.reserved_concurrent_executions == 2 + && !local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.job_queued_check_enabled + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.memory_size == 1024 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.timeout == 90 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.schedule_expression == "rate(10 minutes)" + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes == 5 + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.idle_config[0].idleCount == 1 && local.multi_runner_config.ec2.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount == 4 ) error_message = "The v2 configuration must preserve the queue, scale-up, and scale-down blocks." @@ -520,6 +522,11 @@ run "ec2_v2_input_plan" { error_message = "The v2 configuration must preserve the pool and job-retry blocks." } + assert { + condition = !can(local.multi_runner_config.ec2.orchestration.webhook.lambda.artifact) + error_message = "Runner-control artifacts must be configured globally, not in a per-runner webhook Lambda block." + } + assert { condition = ( local.multi_runner_config.ec2.compute_provider.ec2.user_data.pre_install == "caller-pre" @@ -581,8 +588,8 @@ run "ec2_v2_input_plan" { && local.multi_runner_config.ec2.runner.tags.Scope == "runner" && local.multi_runner_config.ec2.lambda.tags.Scope == "lambda" && local.multi_runner_config.ec2.orchestration.webhook.queue.tags.Scope == "queue" - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_up.tags.Scope == "scale-up" - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale_down.tags.Scope == "scale-down" + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.tags.Scope == "scale-up" + && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.tags.Scope == "scale-down" && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.tags.Scope == "pool" && local.multi_runner_config.ec2.orchestration.webhook.job_retry.tags.Scope == "job-retry" && local.multi_runner_config.ec2.ssm.tags.Scope == "ssm" @@ -653,7 +660,7 @@ run "ec2_v2_input_plan" { local.experimental_config.github.app.id == "12345" && local.experimental_config.github.enterprise_server.url == null && local.experimental_config.orchestration.webhook.eventbridge.enable - && local.experimental_config.orchestration.webhook.lambda.scale.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" + && local.experimental_config.orchestration.webhook.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" && tolist(local.experimental_config.lambda.subnet_ids) == tolist(["subnet-test"]) && length(local.experimental_config.lambda.security_group_ids) == 1 && local.experimental_config.orchestration.webhook.lambda.webhook.artifact.zip == "/private/tmp/forge-test-lambda-cache/webhook.zip" diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index b6d04de8..9ac02319 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -47,19 +47,19 @@ run "platform_ec2_deployment_interface_contract" { "runner = object({", "os = string", "architecture = string", - "boot_time_in_minutes = optional(number, null)", "disable_default_labels = optional(bool, null)", "extra_labels = optional(list(string), null)", "group_name = optional(string, null)", "name_prefix = optional(string, null)", "run_as_root = optional(bool, null)", "run_as = optional(string, null)", + "auto_update_disabled = optional(bool, null)", "orchestration = object({", "webhook = optional(object({", - "maximum_count = number", - "ephemeral = optional(bool, null)", - "jit_config_enabled = optional(bool, null)", - "auto_update_disabled = optional(bool, null)", + "boot_time_in_minutes = optional(number, null)", + "ephemeral = optional(bool, null)", + "jit_config_enabled = optional(bool, null)", + "maximum_count = number", "hooks = optional(object({", "job_started = optional(string, null)", "job_completed = optional(string, null)", @@ -88,10 +88,11 @@ run "platform_ec2_deployment_interface_contract" { "redrive_build_queue = optional(object({", "enabled = optional(bool, null)", "maxReceiveCount = optional(number, null)", - "scale_up = optional(object({", + "scale = optional(object({", + "up = optional(object({", "reserved_concurrent_executions = optional(number, null)", "job_queued_check_enabled = optional(bool, null)", - "scale_down = optional(object({", + "down = optional(object({", "schedule_expression = optional(string, null)", "minimum_running_time_in_minutes = optional(number, null)", "idle_config = optional(list(object({", @@ -341,7 +342,7 @@ run "platform_ec2_deployment_interface_contract" { condition = ( output.expected_input_variable_count == 4 && output.expected_output_value_count == 6 - && output.expected_interface_literal_count == 287 + && output.expected_interface_literal_count == 288 ) 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 fbd99eed..a7f14796 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -18,7 +18,13 @@ run "platform_ec2_deployment_contract" { "app = var.runner_configs.github_app", "enterprise_server = {", "orchestration = {", - "maximum_count = number", + "boot_time_in_minutes = optional(number, null)", + "ephemeral = optional(bool, null)", + "jit_config_enabled = optional(bool, null)", + "maximum_count = number", + "scale = optional(object({", + "up = optional(object({", + "down = optional(object({", "runner_config.orchestration.webhook.matcherConfig.labelMatchers[0]", "matcher_index, labels in runner_config.orchestration.webhook.matcherConfig.labelMatchers", "kms_key_id = aws_kms_key.github.arn", diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index 1184e2f0..aba13cc3 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -22,15 +22,12 @@ variable "runner_configs" { runner = object({ os = string architecture = string - boot_time_in_minutes = optional(number, null) disable_default_labels = optional(bool, null) extra_labels = optional(list(string), null) group_name = optional(string, null) name_prefix = optional(string, null) run_as_root = optional(bool, null) run_as = optional(string, null) - ephemeral = optional(bool, null) - jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, null) tags = optional(map(string), {}) hooks = optional(object({ @@ -63,7 +60,10 @@ variable "runner_configs" { orchestration = object({ webhook = optional(object({ runner = object({ - maximum_count = number + boot_time_in_minutes = optional(number, null) + ephemeral = optional(bool, null) + jit_config_enabled = optional(bool, null) + maximum_count = number }) github = optional(object({ @@ -98,29 +98,31 @@ variable "runner_configs" { }), {}) lambda = optional(object({ - scale_up = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - reserved_concurrent_executions = optional(number, null) - job_queued_check_enabled = optional(bool, null) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) + scale = optional(object({ + up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) }), {}) - tags = optional(map(string), {}) - }), {}) - scale_down = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - schedule_expression = optional(string, null) - minimum_running_time_in_minutes = optional(number, null) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), null) - tags = optional(map(string), {}) }), {}) pool = optional(object({ memory_size = optional(number, null) diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index b2bf2fb9..e8b66e08 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -26,9 +26,13 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived GitHub API, common Lambda substrate, SSM KMS and housekeeper artifact, observability, and EC2 network settings are populated in the experimental global blocks. -- Webhook capacity, GitHub scope, matching, queue, scale-up, scale-down, pool, - and retry settings are nested below `orchestration.webhook`; the common - per-configuration `lambda` block remains orchestration-provider-neutral. +- Webhook runner lifecycle, capacity, startup timing, GitHub scope, matching, + queue, scale-up, scale-down, pool, and retry settings are nested below + `orchestration.webhook`; the common per-configuration `lambda` block remains + orchestration-provider-neutral. +- The shared runner-control archive is selected globally at + `orchestration.webhook.lambda.artifact`; per-runner webhook Lambda blocks do + not select deployment artifacts. - The v2 runner graph changes internal Terraform addresses. Upstream moves the prior experimental `module.runner_stacks` address to `module.runner_configs`; migration from the stable v1 graph and exact-ref @@ -129,7 +133,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. The public runner
shape follows the nested experimental multi\_runner\_config EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 configurations.

runner\_specs[*] object fields:
- runner : Provider-neutral OS, architecture, labels, registration,
hooks, and IAM-policy configuration.
- lambda : Provider-neutral per-configuration Lambda runtime,
network, role, and tag overrides.
- orchestration : Exactly one demand-controller provider. The webhook
provider owns runner capacity, GitHub scope, matching,
queues, scaling, pools, and job retry.
- ssm : Per-configuration paths, tags, parameter tags, and
housekeeper settings, including its Lambda artifact.
- observability : Per-configuration logging, tracing, and metrics overrides.
- compute\_provider: Nested v2 EC2 provider configuration.
- tags : Per-configuration resource tags.

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/security-group fields: Per-configuration networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
boot_time_in_minutes = optional(number, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = object({
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale_up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
scale_down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | EC2 deployment configuration for GitHub Actions runners. The public runner
shape follows the nested experimental multi\_runner\_config EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 configurations.

runner\_specs[*] object fields:
- runner : Provider-neutral OS, architecture, labels, bootstrap,
hooks, and IAM-policy configuration.
- lambda : Provider-neutral per-configuration Lambda runtime,
network, role, and tag overrides.
- orchestration : Exactly one demand-controller provider. The webhook
provider owns runner lifecycle, capacity, and startup
timing, GitHub scope, matching, queues, scaling, pools,
and job retry.
- ssm : Per-configuration paths, tags, parameter tags, and
housekeeper settings, including its Lambda artifact.
- observability : Per-configuration logging, tracing, and metrics overrides.
- compute\_provider: Nested v2 EC2 provider configuration.
- tags : Per-configuration resource tags.

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/security-group fields: Per-configuration networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| 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 ce86095d..a0f008bd 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -143,19 +143,19 @@ run "platform_forge_runners_interface_contract" { "runner = object({", "os = string", "architecture = string", - "boot_time_in_minutes = optional(number, null)", "disable_default_labels = optional(bool, null)", "extra_labels = optional(list(string), null)", "group_name = optional(string, null)", "name_prefix = optional(string, null)", "run_as_root = optional(bool, null)", "run_as = optional(string, null)", + "auto_update_disabled = optional(bool, null)", "orchestration = object({", "webhook = optional(object({", - "maximum_count = number", - "ephemeral = optional(bool, null)", - "jit_config_enabled = optional(bool, null)", - "auto_update_disabled = optional(bool, null)", + "boot_time_in_minutes = optional(number, null)", + "ephemeral = optional(bool, null)", + "jit_config_enabled = optional(bool, null)", + "maximum_count = number", "hooks = optional(object({", "job_started = optional(string, null)", "job_completed = optional(string, null)", @@ -184,10 +184,11 @@ run "platform_forge_runners_interface_contract" { "redrive_build_queue = optional(object({", "enabled = optional(bool, null)", "maxReceiveCount = optional(number, null)", - "scale_up = optional(object({", + "scale = optional(object({", + "up = optional(object({", "reserved_concurrent_executions = optional(number, null)", "job_queued_check_enabled = optional(bool, null)", - "scale_down = optional(object({", + "down = optional(object({", "schedule_expression = optional(string, null)", "minimum_running_time_in_minutes = optional(number, null)", "idle_config = optional(list(object({", @@ -341,15 +342,16 @@ run "platform_forge_runners_interface_contract" { "- subnet_ids : Default subnets for EC2 runners.", "- vpc_id : VPC that contains both runner and lambda subnets.", "- runner_specs : Map of EC2 runner configurations.", - "- runner : Provider-neutral OS, architecture, labels, registration,", + "- runner : Provider-neutral OS, architecture, labels, bootstrap,", "hooks, and IAM-policy configuration.", "shape follows the nested experimental multi_runner_config EC2 contract and", "is passed directly to the upstream provider-oriented runner stack.", "- lambda : Provider-neutral per-configuration Lambda runtime,", "network, role, and tag overrides.", "- orchestration : Exactly one demand-controller provider. The webhook", - "provider owns runner capacity, GitHub scope, matching,", - "queues, scaling, pools, and job retry.", + "provider owns runner lifecycle, capacity, and startup", + "timing, GitHub scope, matching, queues, scaling, pools,", + "and job retry.", "- ssm : Per-configuration paths, tags, parameter tags, and", "housekeeper settings, including its Lambda artifact.", "- observability : Per-configuration logging, tracing, and metrics overrides.", @@ -445,7 +447,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 == 386 + && output.expected_interface_literal_count == 388 ) 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 cebb15a7..14b1e9fd 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -21,12 +21,16 @@ run "platform_forge_runners_contract" { "runner = object({", "orchestration = object({", "webhook = optional(object({", - "maximum_count = number", + "boot_time_in_minutes = optional(number, null)", + "ephemeral = optional(bool, null)", + "jit_config_enabled = optional(bool, null)", + "maximum_count = number", "lambda = optional(object({", "queue = optional(object({", "visibility_timeout_seconds = optional(number, null)", - "scale_up = optional(object({", - "scale_down = optional(object({", + "scale = optional(object({", + "up = optional(object({", + "down = optional(object({", "pool = optional(object({", "job_retry = optional(object({", "artifact = optional(object({", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index b337df6d..eaf3623a 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -20,15 +20,12 @@ variable "ec2_deployment_specs" { runner = object({ os = string architecture = string - boot_time_in_minutes = optional(number, null) disable_default_labels = optional(bool, null) extra_labels = optional(list(string), null) group_name = optional(string, null) name_prefix = optional(string, null) run_as_root = optional(bool, null) run_as = optional(string, null) - ephemeral = optional(bool, null) - jit_config_enabled = optional(bool, null) auto_update_disabled = optional(bool, null) tags = optional(map(string), {}) hooks = optional(object({ @@ -61,7 +58,10 @@ variable "ec2_deployment_specs" { orchestration = object({ webhook = optional(object({ runner = object({ - maximum_count = number + boot_time_in_minutes = optional(number, null) + ephemeral = optional(bool, null) + jit_config_enabled = optional(bool, null) + maximum_count = number }) github = optional(object({ @@ -96,29 +96,31 @@ variable "ec2_deployment_specs" { }), {}) lambda = optional(object({ - scale_up = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - reserved_concurrent_executions = optional(number, null) - job_queued_check_enabled = optional(bool, null) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) + scale = optional(object({ + up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) }), {}) - tags = optional(map(string), {}) - }), {}) - scale_down = optional(object({ - memory_size = optional(number, null) - timeout = optional(number, null) - schedule_expression = optional(string, null) - minimum_running_time_in_minutes = optional(number, null) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), null) - tags = optional(map(string), {}) }), {}) pool = optional(object({ memory_size = optional(number, null) @@ -368,13 +370,14 @@ variable "ec2_deployment_specs" { - runner_specs : Map of EC2 runner configurations. runner_specs[*] object fields: - - runner : Provider-neutral OS, architecture, labels, registration, + - runner : Provider-neutral OS, architecture, labels, bootstrap, hooks, and IAM-policy configuration. - lambda : Provider-neutral per-configuration Lambda runtime, network, role, and tag overrides. - orchestration : Exactly one demand-controller provider. The webhook - provider owns runner capacity, GitHub scope, matching, - queues, scaling, pools, and job retry. + provider owns runner lifecycle, capacity, and startup + timing, GitHub scope, matching, queues, scaling, pools, + and job retry. - ssm : Per-configuration paths, tags, parameter tags, and housekeeper settings, including its Lambda artifact. - observability : Per-configuration logging, tracing, and metrics overrides. From 2ede29f76251ba8fd88c57b18855f6118a772179 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sat, 15 Aug 2026 23:33:02 +0200 Subject: [PATCH 6/8] refactor(runners): rename orchestration provider selector --- .../main/tenants/acme/runner_settings.hcl | 2 +- .../platform/tenant/runner_settings.hcl | 2 +- modules/platform/ec2_deployment/README.md | 32 ++--- .../ec2_deployment/runner_configs_v2.tf | 6 +- .../ec2_deployment/tests/behavior.tftest.hcl | 110 +++++++++--------- .../tests/interface_contract.tftest.hcl | 10 +- .../tests/source_inventory.tftest.hcl | 6 +- modules/platform/ec2_deployment/variables.tf | 4 +- modules/platform/forge_runners/README.md | 38 +++--- .../tests/interface_contract.tftest.hcl | 18 +-- .../tests/source_inventory.tftest.hcl | 2 +- modules/platform/forge_runners/variables.tf | 30 ++--- 12 files changed, 130 insertions(+), 130 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 8a29555d..cc029e56 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 @@ -76,7 +76,7 @@ locals { group_name = local.runner_group_name run_as = spec.runner_user } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = spec.max_instances diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index 8a29555d..cc029e56 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -76,7 +76,7 @@ locals { group_name = local.runner_group_name run_as = spec.runner_user } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = spec.max_instances diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index 95f1347d..8113fe89 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -29,14 +29,14 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## Operational Notes - This is a breaking input migration: every `runner_specs` entry uses the full - nested v2 shape and must contain `runner`, `orchestration.webhook`, and - `compute_provider.ec2`. The `orchestration.webhook.runner` block owns the + nested v2 shape and must contain `runner`, `orchestration_provider.webhook`, and + `compute_provider.ec2`. The `orchestration_provider.webhook.runner` block owns the `ephemeral` and `jit_config_enabled` lifecycle modes, `maximum_count` capacity limit, and `boot_time_in_minutes` startup estimate. Matching, queue, `lambda.scale.up`, `lambda.scale.down`, pool, and retry settings stay inside that provider block. The legacy flat EC2 shape is not accepted. - The runner-control archive is selected once at - `orchestration.webhook.lambda.artifact`. Per-runner webhook Lambda blocks own + `orchestration_provider.webhook.lambda.artifact`. Per-runner webhook Lambda blocks own only scale and pool runtime settings and cannot select an artifact. - The SSM housekeeper has its own artifact selector under `ssm.housekeeper.lambda.artifact`; Forge points the global selector at the @@ -50,10 +50,10 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. - External runner roles and instance profiles are supported as a pair. Their owner must provision Forge's EC2-tag and hook-SSM permissions because the module does not modify externally managed IAM resources. -- The upstream v2 ref is experimental and mutable. This revision moves the - prior experimental `module.runner_stacks` address to - `module.runner_configs`, but migration from the stable v1 graph remains a - separate review. Inspect existing-state plans for unexpected replacement or +- The upstream v2 ref is experimental and mutable. It does not include an + automatic state move from the prior experimental `module.runner_stacks` + address to `module.runner_configs`, or from the stable v1 graph. Migrate any + existing state explicitly and inspect plans for unexpected replacement or naming collisions before rollout. - Forge currently supplies the v7.10.1 Lambda release artifacts to the experimental Terraform graph. Keep rollout gated until artifact compatibility @@ -66,7 +66,7 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | ~> 1.11 | | [archive](#requirement\_archive) | >= 2.7.0 | | [aws](#requirement\_aws) | >= 6.47 | @@ -75,14 +75,14 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | -| [external](#provider\_external) | 2.4.1 | +|------|---------| +| [aws](#provider\_aws) | >= 6.47 | +| [external](#provider\_external) | >= 2.3 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [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 | experimental-multi-runner-config-v2-20260805 | @@ -90,7 +90,7 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_cloudwatch_log_group.webhook_api_gateway_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_policy.ec2_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_policy.runner_hooks_ssm_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | @@ -111,16 +111,16 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [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
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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| n/a | yes | +| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = 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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| n/a | yes | | [tenant\_configs](#input\_tenant\_configs) | n/a |
object({
ecr_registries = list(string)
tags = map(string)
})
| n/a | yes | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [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. | diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf index 0fa081df..4b7953df 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -91,7 +91,7 @@ locals { legacy_runner_labels = { for key, runner_config in local.ec2_runner_configs : key => concat( - try(runner_config.orchestration.webhook.matcherConfig.labelMatchers[0], []), + try(runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers[0], []), coalesce(runner_config.runner.extra_labels, []), ) } @@ -105,7 +105,7 @@ locals { local.legacy_runner_labels[key], distinct([ for label in flatten([ - for matcher_index, labels in runner_config.orchestration.webhook.matcherConfig.labelMatchers : labels if matcher_index > 0 + for matcher_index, labels in runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers : labels if matcher_index > 0 ]) : label if !contains(local.legacy_runner_labels[key], label) ]), @@ -234,7 +234,7 @@ locals { tags = local.terraform_aws_github_runner_tags } - orchestration = { + orchestration_provider = { webhook = { eventbridge = { enable = true diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index c42119fe..92a61879 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -163,7 +163,7 @@ variables { } } - orchestration = { + orchestration_provider = { webhook = { runner = { boot_time_in_minutes = 7 @@ -375,7 +375,7 @@ variables { os = "linux" architecture = "x64" } - orchestration = { + orchestration_provider = { webhook = { runner = { maximum_count = 1 @@ -423,7 +423,7 @@ run "ec2_v2_input_plan" { condition = alltrue([ for runner_config in values(local.multi_runner_config) : length([ - for provider_config in values(runner_config.orchestration) : provider_config + for provider_config in values(runner_config.orchestration_provider) : provider_config if provider_config != null ]) == 1 ]) @@ -472,58 +472,58 @@ run "ec2_v2_input_plan" { && local.multi_runner_config.ec2.runner.name_prefix == "forge-" && local.multi_runner_config.ec2.runner.run_as_root && local.multi_runner_config.ec2.runner.run_as == "ec2-user" - && local.multi_runner_config.ec2.orchestration.webhook.runner.boot_time_in_minutes == 7 - && local.multi_runner_config.ec2.orchestration.webhook.runner.ephemeral - && !local.multi_runner_config.ec2.orchestration.webhook.runner.jit_config_enabled - && local.multi_runner_config.ec2.orchestration.webhook.runner.maximum_count == 2 + && local.multi_runner_config.ec2.orchestration_provider.webhook.runner.boot_time_in_minutes == 7 + && local.multi_runner_config.ec2.orchestration_provider.webhook.runner.ephemeral + && !local.multi_runner_config.ec2.orchestration_provider.webhook.runner.jit_config_enabled + && local.multi_runner_config.ec2.orchestration_provider.webhook.runner.maximum_count == 2 && local.multi_runner_config.ec2.runner.auto_update_disabled - && local.multi_runner_config.ec2.orchestration.webhook.github.organization_runners + && local.multi_runner_config.ec2.orchestration_provider.webhook.github.organization_runners ) error_message = "The v2 configuration must preserve the common runner and webhook-owned runner and GitHub blocks." } assert { condition = ( - local.multi_runner_config.ec2.orchestration.webhook.queue.delay_webhook_event == 7 - && local.multi_runner_config.ec2.orchestration.webhook.queue.job_queue_retention_in_seconds == 90000 - && local.multi_runner_config.ec2.orchestration.webhook.queue.visibility_timeout_seconds == 240 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.event_source_mapping.batch_size == 5 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 1 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.memory_size == 768 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.timeout == 40 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.reserved_concurrent_executions == 2 - && !local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.job_queued_check_enabled - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.memory_size == 1024 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.timeout == 90 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.schedule_expression == "rate(10 minutes)" - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.minimum_running_time_in_minutes == 5 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.idle_config[0].idleCount == 1 - && local.multi_runner_config.ec2.orchestration.webhook.queue.redrive_build_queue.maxReceiveCount == 4 + local.multi_runner_config.ec2.orchestration_provider.webhook.queue.delay_webhook_event == 7 + && local.multi_runner_config.ec2.orchestration_provider.webhook.queue.job_queue_retention_in_seconds == 90000 + && local.multi_runner_config.ec2.orchestration_provider.webhook.queue.visibility_timeout_seconds == 240 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size == 5 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 1 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.memory_size == 768 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.timeout == 40 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions == 2 + && !local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.memory_size == 1024 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.timeout == 90 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.schedule_expression == "rate(10 minutes)" + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes == 5 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.idle_config[0].idleCount == 1 + && local.multi_runner_config.ec2.orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount == 4 ) error_message = "The v2 configuration must preserve the queue, scale-up, and scale-down blocks." } assert { condition = ( - local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.config[0].size == 1 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.memory_size == 512 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.timeout == 75 - && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.reserved_concurrent_executions == 2 - && !local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.include_busy_runners - && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.runner_owner == "cisco-open" - && local.multi_runner_config.ec2.orchestration.webhook.job_retry.enabled - && local.multi_runner_config.ec2.orchestration.webhook.job_retry.delay_in_seconds == 120 - && local.multi_runner_config.ec2.orchestration.webhook.job_retry.delay_backoff == 3 - && local.multi_runner_config.ec2.orchestration.webhook.job_retry.max_attempts == 2 - && local.multi_runner_config.ec2.orchestration.webhook.job_retry.lambda.memory_size == 512 - && local.multi_runner_config.ec2.orchestration.webhook.job_retry.lambda.reserved_concurrent_executions == 3 - && local.multi_runner_config.ec2.orchestration.webhook.job_retry.lambda.timeout == 45 + local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.config[0].size == 1 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.memory_size == 512 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.timeout == 75 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions == 2 + && !local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.include_busy_runners + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.runner_owner == "cisco-open" + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.enabled + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.delay_in_seconds == 120 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.delay_backoff == 3 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.max_attempts == 2 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.lambda.memory_size == 512 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions == 3 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.lambda.timeout == 45 ) error_message = "The v2 configuration must preserve the pool and job-retry blocks." } assert { - condition = !can(local.multi_runner_config.ec2.orchestration.webhook.lambda.artifact) + condition = !can(local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.artifact) error_message = "Runner-control artifacts must be configured globally, not in a per-runner webhook Lambda block." } @@ -570,14 +570,14 @@ run "ec2_v2_input_plan" { assert { condition = ( - length(local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.labelMatchers) == 2 - && tolist(local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) - && tolist(local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) - && local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.exactMatch - && local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.bidirectionalLabelMatch - && local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.priority == 5 - && local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.enableDynamicLabels - && tolist(local.multi_runner_config.ec2.orchestration.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) + length(local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.labelMatchers) == 2 + && tolist(local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) + && tolist(local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) + && local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.exactMatch + && local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch + && local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.priority == 5 + && local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.enableDynamicLabels + && tolist(local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) ) error_message = "The v2 configuration must preserve matcher configuration." } @@ -587,11 +587,11 @@ run "ec2_v2_input_plan" { local.multi_runner_config.ec2.tags.Scope == "entry" && local.multi_runner_config.ec2.runner.tags.Scope == "runner" && local.multi_runner_config.ec2.lambda.tags.Scope == "lambda" - && local.multi_runner_config.ec2.orchestration.webhook.queue.tags.Scope == "queue" - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.up.tags.Scope == "scale-up" - && local.multi_runner_config.ec2.orchestration.webhook.lambda.scale.down.tags.Scope == "scale-down" - && local.multi_runner_config.ec2.orchestration.webhook.lambda.pool.tags.Scope == "pool" - && local.multi_runner_config.ec2.orchestration.webhook.job_retry.tags.Scope == "job-retry" + && local.multi_runner_config.ec2.orchestration_provider.webhook.queue.tags.Scope == "queue" + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.tags.Scope == "scale-up" + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.tags.Scope == "scale-down" + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.tags.Scope == "pool" + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.tags.Scope == "job-retry" && local.multi_runner_config.ec2.ssm.tags.Scope == "ssm" && local.multi_runner_config.ec2.ssm.parameters.tags.Scope == "ssm-parameters" && local.multi_runner_config.ec2.ssm.housekeeper.tags.Scope == "ssm-housekeeper" @@ -659,11 +659,11 @@ run "ec2_v2_input_plan" { condition = ( local.experimental_config.github.app.id == "12345" && local.experimental_config.github.enterprise_server.url == null - && local.experimental_config.orchestration.webhook.eventbridge.enable - && local.experimental_config.orchestration.webhook.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" + && local.experimental_config.orchestration_provider.webhook.eventbridge.enable + && local.experimental_config.orchestration_provider.webhook.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" && tolist(local.experimental_config.lambda.subnet_ids) == tolist(["subnet-test"]) && length(local.experimental_config.lambda.security_group_ids) == 1 - && local.experimental_config.orchestration.webhook.lambda.webhook.artifact.zip == "/private/tmp/forge-test-lambda-cache/webhook.zip" + && local.experimental_config.orchestration_provider.webhook.lambda.webhook.artifact.zip == "/private/tmp/forge-test-lambda-cache/webhook.zip" && local.experimental_config.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/00000000-0000-0000-0000-000000000000" && local.experimental_config.ssm.housekeeper.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" && local.experimental_config.observability.logs.level == "info" @@ -687,8 +687,8 @@ run "ec2_v2_input_plan" { assert { condition = ( - local.experimental_config.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn == aws_cloudwatch_log_group.webhook_api_gateway_access.arn - && local.experimental_config.orchestration.webhook.lambda.webhook.api_gateway_access_log_settings.format == local.webhook_api_gateway_access_log_format + local.experimental_config.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn == aws_cloudwatch_log_group.webhook_api_gateway_access.arn + && local.experimental_config.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format == local.webhook_api_gateway_access_log_format ) error_message = "Forge webhook API Gateway access-log settings must be carried by the experimental webhook contract." } diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index 9ac02319..49ef1c8d 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -54,7 +54,7 @@ run "platform_ec2_deployment_interface_contract" { "run_as_root = optional(bool, null)", "run_as = optional(string, null)", "auto_update_disabled = optional(bool, null)", - "orchestration = object({", + "orchestration_provider = object({", "webhook = optional(object({", "boot_time_in_minutes = optional(number, null)", "ephemeral = optional(bool, null)", @@ -233,7 +233,7 @@ run "platform_ec2_deployment_interface_contract" { "enableDynamicLabels = optional(bool, false)", "awsDynamicLabelsPolicy = optional(object({", "for runner_config in values(var.runner_configs.runner_specs) :", - "for provider_config in values(runner_config.orchestration) : provider_config", + "for provider_config in values(runner_config.orchestration_provider) : provider_config", "if provider_config != null", "error_message = \"Each Forge runner configuration must select exactly one non-null orchestration provider.\"", "variable \"tenant_configs\"", @@ -253,8 +253,8 @@ run "platform_ec2_deployment_interface_contract" { "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.\"", "active_ec2_subnet_ids = toset(flatten([", "legacy_runner_labels = {", - "runner_config.orchestration.webhook.matcherConfig.labelMatchers[0]", - "matcher_index, labels in runner_config.orchestration.webhook.matcherConfig.labelMatchers", + "runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers[0]", + "matcher_index, labels in runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers", "!contains(local.legacy_runner_labels[key], label)", "ec2_default_ami_filters = {", "windows = { name = [\"Windows_Server-2022-English-Full-ECS_Optimized-*\"] }", @@ -290,7 +290,7 @@ run "platform_ec2_deployment_interface_contract" { "experimental_config = {", "app = var.runner_configs.github_app", "enterprise_server = {", - "orchestration = {", + "orchestration_provider = {", "url = try(trimspace(var.runner_configs.ghes_url), \"\") == \"\" ? null : var.runner_configs.ghes_url", "artifact = {", "kms_key_id = aws_kms_key.github.arn", diff --git a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl index a7f14796..2141adca 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -17,7 +17,7 @@ run "platform_ec2_deployment_contract" { "experimental_config = {", "app = var.runner_configs.github_app", "enterprise_server = {", - "orchestration = {", + "orchestration_provider = {", "boot_time_in_minutes = optional(number, null)", "ephemeral = optional(bool, null)", "jit_config_enabled = optional(bool, null)", @@ -25,8 +25,8 @@ run "platform_ec2_deployment_contract" { "scale = optional(object({", "up = optional(object({", "down = optional(object({", - "runner_config.orchestration.webhook.matcherConfig.labelMatchers[0]", - "matcher_index, labels in runner_config.orchestration.webhook.matcherConfig.labelMatchers", + "runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers[0]", + "matcher_index, labels in runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers", "kms_key_id = aws_kms_key.github.arn", "multi_runner_config = local.multi_runner_config", "resource \"aws_cloudwatch_log_group\" \"webhook_api_gateway_access\"", diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index aba13cc3..bc08bdc3 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -57,7 +57,7 @@ variable "runner_configs" { }), {}) }), {}) - orchestration = object({ + orchestration_provider = object({ webhook = optional(object({ runner = object({ boot_time_in_minutes = optional(number, null) @@ -339,7 +339,7 @@ variable "runner_configs" { condition = alltrue([ for runner_config in values(var.runner_configs.runner_specs) : length([ - for provider_config in values(runner_config.orchestration) : provider_config + for provider_config in values(runner_config.orchestration_provider) : provider_config if provider_config != null ]) == 1 ]) diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index e8b66e08..9abcdc51 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -20,7 +20,7 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived - The tenant `deployment_config` defines the GitHub App, runner group, repository selection, and IAM bridge. - EC2 and ARC runner configurations can be enabled for the same tenant; workflows choose by labels. - `ec2_deployment_specs.runner_specs` uses the full nested v2 shape; every entry - must configure `runner`, `orchestration.webhook`, and `compute_provider.ec2`. + must configure `runner`, `orchestration_provider.webhook`, and `compute_provider.ec2`. - `ec2_deployment` enriches that public shape with Forge-managed settings and passes it through upstream's `experimental.multi_runner_config`; shared GitHub API, common Lambda substrate, SSM KMS and housekeeper artifact, @@ -28,16 +28,16 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived global blocks. - Webhook runner lifecycle, capacity, startup timing, GitHub scope, matching, queue, scale-up, scale-down, pool, and retry settings are nested below - `orchestration.webhook`; the common per-configuration `lambda` block remains + `orchestration_provider.webhook`; the common per-configuration `lambda` block remains orchestration-provider-neutral. - The shared runner-control archive is selected globally at - `orchestration.webhook.lambda.artifact`; per-runner webhook Lambda blocks do + `orchestration_provider.webhook.lambda.artifact`; per-runner webhook Lambda blocks do not select deployment artifacts. -- The v2 runner graph changes internal Terraform addresses. Upstream moves the - prior experimental `module.runner_stacks` address to - `module.runner_configs`; migration from the stable v1 graph and exact-ref - Lambda artifact compatibility still require separate validation for existing - deployments. +- The v2 runner graph changes internal Terraform addresses. Upstream does not + include an automatic state move from the prior experimental + `module.runner_stacks` address to `module.runner_configs`, or from the stable + v1 graph. Existing deployments require an explicit state migration and + separate exact-ref Lambda artifact validation. - Changing GitHub App or runner-group settings can affect job routing immediately. ## Deployment version inventory @@ -67,7 +67,7 @@ tag with the expected ref in `release_versions.yml`. ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | ~> 1.11 | | [archive](#requirement\_archive) | >= 2.7.0 | | [aws](#requirement\_aws) | >= 6.47 | @@ -80,16 +80,16 @@ tag with the expected ref in `release_versions.yml`. ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | -| [null](#provider\_null) | 3.3.1 | -| [random](#provider\_random) | 3.9.0 | -| [time](#provider\_time) | 0.14.1 | +|------|---------| +| [aws](#provider\_aws) | >= 6.47 | +| [null](#provider\_null) | >= 3.2 | +| [random](#provider\_random) | >= 3.6 | +| [time](#provider\_time) | >= 0.13 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [arc\_runners](#module\_arc\_runners) | ../arc_deployment | n/a | | [ec2\_runners](#module\_ec2\_runners) | ../ec2_deployment | n/a | | [forge\_trust\_validator](#module\_forge\_trust\_validator) | ./forge_trust_validator | n/a | @@ -102,7 +102,7 @@ tag with the expected ref in `release_versions.yml`. ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_iam_policy.ecr_access_for_ec2_instances](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_policy.role_assumption_for_forge_runners](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_servicecatalogappregistry_application.forge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/servicecatalogappregistry_application) | resource | @@ -127,13 +127,13 @@ tag with the expected ref in `release_versions.yml`. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [arc\_deployment\_specs](#input\_arc\_deployment\_specs) | Deployment configuration for Azure Container Apps (ARC) runners.

Top-level fields:
- cluster\_name : Name of the EKS cluster used for ARC runners.
- migrate\_cluster: Optional flag to indicate a one-time migration or
blue/green cutover of the ARC runner cluster.
- runner\_specs : Map of ARC runner pool keys to their sizing and
container resource settings.

runner\_specs[*] object fields:
- runner\_size.max\_runners: Maximum concurrent ARC runners for this pool.
- runner\_size.min\_runners: Minimum number of warm runners.
- scale\_set\_name : Logical name for the scale set / pool.
- scale\_set\_type : Backing type for the scale set (for example,
kubernetes or containerapp, depending on integration).
- scale\_set\_labels : GitHub runner labels advertised by this ARC
scale set.
- container\_images : Container images used by the ARC runner,
sidecars, and DinD containers.
- container\_limits\_cpu : CPU limit for the runner container.
- container\_limits\_memory : Memory limit for the runner container.
- container\_requests\_cpu : CPU request (baseline reservation).
- container\_requests\_memory : Memory request (baseline reservation).
- volume\_requests\_storage\_size: Size of attached storage for the runner.
- volume\_requests\_storage\_type: Storage class or type for attached volume. |
object({
cluster_name = string
migrate_cluster = optional(bool, false)
runner_specs = map(object({
runner_size = object({
max_runners = number
min_runners = number
})
scale_set_name = string
scale_set_type = string
scale_set_labels = list(string)
container_images = optional(object({
actions_runner = optional(string, "ghcr.io/actions/actions-runner:latest")
busybox = optional(string, "public.ecr.aws/docker/library/busybox:stable")
dind_rootless = optional(string, "public.ecr.aws/docker/library/docker:dind-rootless")
}), {})
container_limits_cpu = string
container_limits_memory = string
container_requests_cpu = string
container_requests_memory = string
volume_requests_storage_size = string
volume_requests_storage_type = string
}))
})
| n/a | yes | | [aws\_profile](#input\_aws\_profile) | AWS profile to use. | `string` | n/a | yes | | [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. The public runner
shape follows the nested experimental multi\_runner\_config EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 configurations.

runner\_specs[*] object fields:
- runner : Provider-neutral OS, architecture, labels, bootstrap,
hooks, and IAM-policy configuration.
- lambda : Provider-neutral per-configuration Lambda runtime,
network, role, and tag overrides.
- orchestration : Exactly one demand-controller provider. The webhook
provider owns runner lifecycle, capacity, and startup
timing, GitHub scope, matching, queues, scaling, pools,
and job retry.
- ssm : Per-configuration paths, tags, parameter tags, and
housekeeper settings, including its Lambda artifact.
- observability : Per-configuration logging, tracing, and metrics overrides.
- compute\_provider: Nested v2 EC2 provider configuration.
- tags : Per-configuration resource tags.

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/security-group fields: Per-configuration networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | EC2 deployment configuration for GitHub Actions runners. The public runner
shape follows the nested experimental multi\_runner\_config EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 configurations.

runner\_specs[*] object fields:
- runner : Provider-neutral OS, architecture, labels, bootstrap,
hooks, and IAM-policy configuration.
- lambda : Provider-neutral per-configuration Lambda runtime,
network, role, and tag overrides.
- orchestration\_provider : Exactly one demand-controller provider. The webhook
provider owns runner lifecycle, capacity, and startup
timing, GitHub scope, matching, queues, scaling, pools,
and job retry.
- ssm : Per-configuration paths, tags, parameter tags, and
housekeeper settings, including its Lambda artifact.
- observability : Per-configuration logging, tracing, and metrics overrides.
- compute\_provider : Nested v2 EC2 provider configuration.
- tags : Per-configuration resource tags.

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/security-group fields: Per-configuration networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| 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 | @@ -142,7 +142,7 @@ tag with the expected ref in `release_versions.yml`. ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [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. | diff --git a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl index a0f008bd..6a854756 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -150,7 +150,7 @@ run "platform_forge_runners_interface_contract" { "run_as_root = optional(bool, null)", "run_as = optional(string, null)", "auto_update_disabled = optional(bool, null)", - "orchestration = object({", + "orchestration_provider = object({", "webhook = optional(object({", "boot_time_in_minutes = optional(number, null)", "ephemeral = optional(bool, null)", @@ -329,7 +329,7 @@ run "platform_forge_runners_interface_contract" { "enableDynamicLabels = optional(bool, false)", "awsDynamicLabelsPolicy = optional(object({", "for runner_config in values(var.ec2_deployment_specs.runner_specs) :", - "for provider_config in values(runner_config.orchestration) : provider_config", + "for provider_config in values(runner_config.orchestration_provider) : provider_config", "if provider_config != null", "error_message = \"Each Forge runner configuration must select exactly one non-null orchestration provider.\"", "length(runner_config.compute_provider.ec2[*]) == 1", @@ -342,21 +342,21 @@ run "platform_forge_runners_interface_contract" { "- subnet_ids : Default subnets for EC2 runners.", "- vpc_id : VPC that contains both runner and lambda subnets.", "- runner_specs : Map of EC2 runner configurations.", - "- runner : Provider-neutral OS, architecture, labels, bootstrap,", + "- runner : Provider-neutral OS, architecture, labels, bootstrap,", "hooks, and IAM-policy configuration.", "shape follows the nested experimental multi_runner_config EC2 contract and", "is passed directly to the upstream provider-oriented runner stack.", - "- lambda : Provider-neutral per-configuration Lambda runtime,", + "- lambda : Provider-neutral per-configuration Lambda runtime,", "network, role, and tag overrides.", - "- orchestration : Exactly one demand-controller provider. The webhook", + "- orchestration_provider : Exactly one demand-controller provider. The webhook", "provider owns runner lifecycle, capacity, and startup", "timing, GitHub scope, matching, queues, scaling, pools,", "and job retry.", - "- ssm : Per-configuration paths, tags, parameter tags, and", + "- ssm : Per-configuration paths, tags, parameter tags, and", "housekeeper settings, including its Lambda artifact.", - "- observability : Per-configuration logging, tracing, and metrics overrides.", - "- compute_provider: Nested v2 EC2 provider configuration.", - "- tags : Per-configuration resource tags.", + "- observability : Per-configuration logging, tracing, and metrics overrides.", + "- compute_provider : Nested v2 EC2 provider configuration.", + "- tags : Per-configuration resource tags.", "compute_provider.ec2 fields:", "- ami : Upstream-compatible EC2 AMI configuration.", "- instance_profile: Optional externally managed instance profile. It requires", diff --git a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl index 14b1e9fd..f2851879 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -19,7 +19,7 @@ run "platform_forge_runners_contract" { "resource \"random_id\" \"random\"", "runner_specs = var.ec2_deployment_specs.runner_specs", "runner = object({", - "orchestration = object({", + "orchestration_provider = object({", "webhook = optional(object({", "boot_time_in_minutes = optional(number, null)", "ephemeral = optional(bool, null)", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index eaf3623a..58b6c0cb 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -55,7 +55,7 @@ variable "ec2_deployment_specs" { }), {}) }), {}) - orchestration = object({ + orchestration_provider = object({ webhook = optional(object({ runner = object({ boot_time_in_minutes = optional(number, null) @@ -337,7 +337,7 @@ variable "ec2_deployment_specs" { condition = alltrue([ for runner_config in values(var.ec2_deployment_specs.runner_specs) : length([ - for provider_config in values(runner_config.orchestration) : provider_config + for provider_config in values(runner_config.orchestration_provider) : provider_config if provider_config != null ]) == 1 ]) @@ -370,19 +370,19 @@ variable "ec2_deployment_specs" { - runner_specs : Map of EC2 runner configurations. runner_specs[*] object fields: - - runner : Provider-neutral OS, architecture, labels, bootstrap, - hooks, and IAM-policy configuration. - - lambda : Provider-neutral per-configuration Lambda runtime, - network, role, and tag overrides. - - orchestration : Exactly one demand-controller provider. The webhook - provider owns runner lifecycle, capacity, and startup - timing, GitHub scope, matching, queues, scaling, pools, - and job retry. - - ssm : Per-configuration paths, tags, parameter tags, and - housekeeper settings, including its Lambda artifact. - - observability : Per-configuration logging, tracing, and metrics overrides. - - compute_provider: Nested v2 EC2 provider configuration. - - tags : Per-configuration resource tags. + - runner : Provider-neutral OS, architecture, labels, bootstrap, + hooks, and IAM-policy configuration. + - lambda : Provider-neutral per-configuration Lambda runtime, + network, role, and tag overrides. + - orchestration_provider : Exactly one demand-controller provider. The webhook + provider owns runner lifecycle, capacity, and startup + timing, GitHub scope, matching, queues, scaling, pools, + and job retry. + - ssm : Per-configuration paths, tags, parameter tags, and + housekeeper settings, including its Lambda artifact. + - observability : Per-configuration logging, tracing, and metrics overrides. + - compute_provider : Nested v2 EC2 provider configuration. + - tags : Per-configuration resource tags. compute_provider.ec2 fields: - ami : Upstream-compatible EC2 AMI configuration. From 986eee40e554ed148fa9a51a302f73c105f20b42 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Sun, 16 Aug 2026 00:37:19 +0200 Subject: [PATCH 7/8] fix(runners): align access log contract assertion --- tests/iac/terraform_contract_test.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/iac/terraform_contract_test.py b/tests/iac/terraform_contract_test.py index af05af11..7990dda9 100644 --- a/tests/iac/terraform_contract_test.py +++ b/tests/iac/terraform_contract_test.py @@ -398,7 +398,13 @@ def test_runner_webhook_enables_upstream_api_gateway_access_logs() -> None: deployment_tf = read_repo_file( 'modules/platform/ec2_deployment/main.tf' ) + runner_configs_tf = read_repo_file( + 'modules/platform/ec2_deployment/runner_configs_v2.tf' + ) runner_module = hcl_block(deployment_tf, 'module', 'runners') + experimental_config = assignment_map_block( + runner_configs_tf, 'experimental_config' + ) log_group = hcl_block( deployment_tf, 'resource', @@ -418,10 +424,16 @@ def test_runner_webhook_enables_upstream_api_gateway_access_logs() -> None: 'integrationErrorMessage = "$context.integrationErrorMessage"', ], ) - assert_contains_all( + assert re.search( + r'^\s*experimental\s*=\s*local\.experimental_config$', runner_module, + re.MULTILINE, + ) + assert_contains_all( + experimental_config, [ - 'webhook_lambda_apigateway_access_log_settings = {', + 'orchestration_provider = {', + 'api_gateway_access_log_settings = {', 'destination_arn = aws_cloudwatch_log_group.webhook_api_gateway_access.arn', 'format = local.webhook_api_gateway_access_log_format', ], From 357d4c4bd3cb511e248d8c99d04e02c94fcdaa9c Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Mon, 17 Aug 2026 19:27:26 +0200 Subject: [PATCH 8/8] refactor(runners): adopt AWS compute provider namespace --- .../main/tenants/acme/runner_settings.hcl | 94 +++---- .../platform/tenant/runner_settings.hcl | 94 +++---- modules/platform/ec2_deployment/README.md | 20 +- .../ec2_deployment/runner_configs_v2.tf | 44 ++-- .../ec2_deployment/tests/behavior.tftest.hcl | 208 ++++++++------- .../tests/interface_contract.tftest.hcl | 19 +- .../tests/source_inventory.tftest.hcl | 2 + modules/platform/ec2_deployment/variables.tf | 240 ++++++++--------- modules/platform/forge_runners/README.md | 24 +- .../tests/interface_contract.tftest.hcl | 11 +- .../tests/source_inventory.tftest.hcl | 1 + modules/platform/forge_runners/variables.tf | 242 +++++++++--------- 12 files changed, 514 insertions(+), 485 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 cc029e56..46167e4e 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 @@ -146,55 +146,57 @@ locals { } } compute_provider = { - ec2 = { - 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"] + aws = { + ec2 = { + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" } - owners = [spec.ami_owner] - kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { - arn = spec.ami_kms_key_arn + 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 + pre_install = "# No pre-install steps." + } + 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 + }] } - 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 - pre_install = "# No pre-install steps." - } - 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 - }] } } } diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index cc029e56..46167e4e 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -146,55 +146,57 @@ locals { } } compute_provider = { - ec2 = { - 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"] + aws = { + ec2 = { + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" } - owners = [spec.ami_owner] - kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { - arn = spec.ami_kms_key_arn + 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 + pre_install = "# No pre-install steps." + } + 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 + }] } - 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 - pre_install = "# No pre-install steps." - } - 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 - }] } } } diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index 8113fe89..efa05732 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -30,7 +30,7 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. - This is a breaking input migration: every `runner_specs` entry uses the full nested v2 shape and must contain `runner`, `orchestration_provider.webhook`, and - `compute_provider.ec2`. The `orchestration_provider.webhook.runner` block owns the + `compute_provider.aws.ec2`. The `orchestration_provider.webhook.runner` block owns the `ephemeral` and `jit_config_enabled` lifecycle modes, `maximum_count` capacity limit, and `boot_time_in_minutes` startup estimate. Matching, queue, `lambda.scale.up`, `lambda.scale.down`, pool, and retry settings stay inside @@ -66,7 +66,7 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | ~> 1.11 | | [archive](#requirement\_archive) | >= 2.7.0 | | [aws](#requirement\_aws) | >= 6.47 | @@ -75,14 +75,14 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.47 | -| [external](#provider\_external) | >= 2.3 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | +| [external](#provider\_external) | 2.4.1 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [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 | experimental-multi-runner-config-v2-20260805 | @@ -90,7 +90,7 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_cloudwatch_log_group.webhook_api_gateway_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_policy.ec2_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_policy.runner_hooks_ssm_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | @@ -111,16 +111,16 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [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
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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| n/a | yes | +| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = 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_specs = map(object({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

compute_provider = object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
}), {})
})

}))
})
| n/a | yes | | [tenant\_configs](#input\_tenant\_configs) | n/a |
object({
ecr_registries = list(string)
tags = map(string)
})
| n/a | yes | ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [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. | diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf index 4b7953df..72efd422 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -73,7 +73,7 @@ locals { 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_config.compute_provider.aws.ec2.subnet_ids == null ? var.network_configs.subnet_ids : runner_config.compute_provider.aws.ec2.subnet_ids ])) # This is the upstream EC2 provider's default AMI selection. Normalize it @@ -149,26 +149,26 @@ locals { ec2_compute_provider = { for key, runner_config in local.ec2_runner_configs : key => merge( - runner_config.compute_provider.ec2, + runner_config.compute_provider.aws.ec2, { - ami = runner_config.compute_provider.ec2.ami == null ? null : merge( - runner_config.compute_provider.ec2.ami, + ami = runner_config.compute_provider.aws.ec2.ami == null ? null : merge( + runner_config.compute_provider.aws.ec2.ami, { filter = merge( local.ec2_default_ami_filters[key], - runner_config.compute_provider.ec2.ami.filter, + runner_config.compute_provider.aws.ec2.ami.filter, ) } ) user_data = merge( - runner_config.compute_provider.ec2.user_data, + runner_config.compute_provider.aws.ec2.user_data, { 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 + runner_config.compute_provider.aws.ec2.user_data.content == null + && runner_config.compute_provider.aws.ec2.user_data.template == null + ) ? "${local.user_data_prefix}/user_data_${runner_config.runner.os}.tftpl" : runner_config.compute_provider.aws.ec2.user_data.template post_install = join("\n", compact([ - runner_config.compute_provider.ec2.user_data.post_install, + runner_config.compute_provider.aws.ec2.user_data.post_install, templatefile( local.userdata_template_post_install, { @@ -180,8 +180,8 @@ locals { ])) } ) - 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) + log_files = coalesce(runner_config.compute_provider.aws.ec2.log_files, local.forge_ec2_log_files[key]) + tags = merge(var.tenant_configs.tags, runner_config.compute_provider.aws.ec2.tags) } ) } @@ -213,7 +213,9 @@ locals { }) }) compute_provider = { - ec2 = local.ec2_compute_provider[key] + aws = { + ec2 = local.ec2_compute_provider[key] + } } }) } @@ -278,13 +280,15 @@ locals { } compute_provider = { - ec2 = { - vpc_id = var.network_configs.vpc_id - subnet_ids = var.network_configs.subnet_ids - runner_binaries = { - syncer = { - artifact = { - zip = "${data.external.download_lambdas.result.path}/runner-binaries-syncer.zip" + aws = { + ec2 = { + vpc_id = var.network_configs.vpc_id + subnet_ids = var.network_configs.subnet_ids + runner_binaries = { + syncer = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/runner-binaries-syncer.zip" + } } } } diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index 92a61879..847b3c44 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -300,73 +300,75 @@ variables { } } 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"] + aws = { + ec2 = { + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" } - owners = ["123456789012"] - kms_key = { - arn = "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + 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"] + managed_security_group_enabled = false + egress_rules = [{ + cidr_blocks = ["10.0.0.0/8"] + ipv6_cidr_blocks = [] + prefix_list_ids = [] + from_port = 443 + protocol = "tcp" + security_groups = [] + self = false + to_port = 443 + description = "HTTPS" + }] + instance_profile_path = "/runner/" + key_name = "forge-test" + associate_public_ipv4_address = false + scale_errors = ["InsufficientInstanceCapacity"] + ssm_enabled = true + subnet_ids = ["subnet-override"] + tags = { Configuration = "ec2" } + user_data = { + enabled = true + pre_install = "caller-pre" + post_install = "caller-post" + debug_logging_enabled = true + } + 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" + }] } - 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"] - managed_security_group_enabled = false - egress_rules = [{ - cidr_blocks = ["10.0.0.0/8"] - ipv6_cidr_blocks = [] - prefix_list_ids = [] - from_port = 443 - protocol = "tcp" - security_groups = [] - self = false - to_port = 443 - description = "HTTPS" - }] - instance_profile_path = "/runner/" - key_name = "forge-test" - associate_public_ipv4_address = false - scale_errors = ["InsufficientInstanceCapacity"] - ssm_enabled = true - subnet_ids = ["subnet-override"] - tags = { Configuration = "ec2" } - user_data = { - enabled = true - pre_install = "caller-pre" - post_install = "caller-post" - debug_logging_enabled = true - } - 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" - }] } } } @@ -386,11 +388,13 @@ variables { } } compute_provider = { - ec2 = { - ami = {} - instance_types = ["m7i.large"] - binaries_syncer = { - enabled = false + aws = { + ec2 = { + ami = {} + instance_types = ["m7i.large"] + binaries_syncer = { + enabled = false + } } } } @@ -419,6 +423,14 @@ run "ec2_v2_input_plan" { error_message = "The upstream v2 configuration must preserve every EC2 runner-configuration key." } + assert { + condition = ( + toset(keys(local.multi_runner_config.ec2.compute_provider)) == toset(["aws"]) + && toset(keys(local.multi_runner_config.ec2.compute_provider.aws)) == toset(["ec2"]) + ) + error_message = "Forge must preserve the AWS namespace while keeping ec2 as the runtime compute-provider type." + } + assert { condition = alltrue([ for runner_config in values(local.multi_runner_config) : @@ -437,29 +449,29 @@ run "ec2_v2_input_plan" { assert { condition = ( - tolist(local.multi_runner_config.ec2.compute_provider.ec2.ami.filter.name) == tolist(["forge-*"]) - && local.multi_runner_config.ec2.compute_provider.ec2.ami.id_ssm_parameter == null - && local.multi_runner_config.ec2.compute_provider.ec2.ami.kms_key.arn == "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" - && local.multi_runner_config.ec2.compute_provider.ec2.ebs_optimized + tolist(local.multi_runner_config.ec2.compute_provider.aws.ec2.ami.filter.name) == tolist(["forge-*"]) + && local.multi_runner_config.ec2.compute_provider.aws.ec2.ami.id_ssm_parameter == null + && local.multi_runner_config.ec2.compute_provider.aws.ec2.ami.kms_key.arn == "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.ebs_optimized ) error_message = "The v2 configuration must preserve nested EC2 AMI and fleet configuration." } assert { condition = ( - local.multi_runner_config.ec2.compute_provider.ec2.metadata_options.http_tokens == "optional" - && local.multi_runner_config.ec2.compute_provider.ec2.metadata_options.http_put_response_hop_limit == 2 - && local.multi_runner_config.ec2.compute_provider.ec2.cloudwatch_agent.enabled - && local.multi_runner_config.ec2.compute_provider.ec2.cloudwatch_agent.config == "{\"agent\":{}}" - && !local.multi_runner_config.ec2.compute_provider.ec2.binaries_syncer.enabled - && local.multi_runner_config.ec2.compute_provider.ec2.detailed_monitoring_enabled - && local.multi_runner_config.ec2.compute_provider.ec2.ssm_enabled - && tolist(local.multi_runner_config.ec2.compute_provider.ec2.additional_security_group_ids) == tolist(["sg-runner"]) - && !local.multi_runner_config.ec2.compute_provider.ec2.managed_security_group_enabled - && local.multi_runner_config.ec2.compute_provider.ec2.egress_rules[0].description == "HTTPS" - && local.multi_runner_config.ec2.compute_provider.ec2.instance_profile_path == "/runner/" - && local.multi_runner_config.ec2.compute_provider.ec2.key_name == "forge-test" - && !local.multi_runner_config.ec2.compute_provider.ec2.associate_public_ipv4_address + local.multi_runner_config.ec2.compute_provider.aws.ec2.metadata_options.http_tokens == "optional" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.metadata_options.http_put_response_hop_limit == 2 + && local.multi_runner_config.ec2.compute_provider.aws.ec2.cloudwatch_agent.enabled + && local.multi_runner_config.ec2.compute_provider.aws.ec2.cloudwatch_agent.config == "{\"agent\":{}}" + && !local.multi_runner_config.ec2.compute_provider.aws.ec2.binaries_syncer.enabled + && local.multi_runner_config.ec2.compute_provider.aws.ec2.detailed_monitoring_enabled + && local.multi_runner_config.ec2.compute_provider.aws.ec2.ssm_enabled + && tolist(local.multi_runner_config.ec2.compute_provider.aws.ec2.additional_security_group_ids) == tolist(["sg-runner"]) + && !local.multi_runner_config.ec2.compute_provider.aws.ec2.managed_security_group_enabled + && local.multi_runner_config.ec2.compute_provider.aws.ec2.egress_rules[0].description == "HTTPS" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.instance_profile_path == "/runner/" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.key_name == "forge-test" + && !local.multi_runner_config.ec2.compute_provider.aws.ec2.associate_public_ipv4_address ) error_message = "The v2 configuration must preserve nested EC2 bootstrap, metadata, and networking settings." } @@ -529,15 +541,15 @@ run "ec2_v2_input_plan" { assert { condition = ( - local.multi_runner_config.ec2.compute_provider.ec2.user_data.pre_install == "caller-pre" - && startswith(local.multi_runner_config.ec2.compute_provider.ec2.user_data.post_install, "caller-post\n") - && strcontains(local.multi_runner_config.ec2.compute_provider.ec2.user_data.post_install, "su -l root -c") - && strcontains(local.multi_runner_config.ec2.compute_provider.ec2.user_data.post_install, "--config /root/.docker") - && local.multi_runner_config.ec2.compute_provider.ec2.user_data.debug_logging_enabled - && length(local.multi_runner_config.ec2.compute_provider.ec2.log_files) == 4 - && local.multi_runner_config.ec2.compute_provider.ec2.log_files[3].file_path == "/root/hook.log" - && local.multi_runner_config.ec2.compute_provider.ec2.tags.Environment == "test" - && local.multi_runner_config.ec2.compute_provider.ec2.tags.Configuration == "ec2" + local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.pre_install == "caller-pre" + && startswith(local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.post_install, "caller-post\n") + && strcontains(local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.post_install, "su -l root -c") + && strcontains(local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.post_install, "--config /root/.docker") + && local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.debug_logging_enabled + && length(local.multi_runner_config.ec2.compute_provider.aws.ec2.log_files) == 4 + && local.multi_runner_config.ec2.compute_provider.aws.ec2.log_files[3].file_path == "/root/hook.log" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.tags.Environment == "test" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.tags.Configuration == "ec2" ) error_message = "The v2 configuration must retain Forge user-data, logging, and tag overlays." } @@ -668,9 +680,9 @@ run "ec2_v2_input_plan" { && local.experimental_config.ssm.housekeeper.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" && local.experimental_config.observability.logs.level == "info" && local.experimental_config.observability.logs.retention_in_days == 3 - && local.experimental_config.compute_provider.ec2.vpc_id == "vpc-test" - && tolist(local.experimental_config.compute_provider.ec2.subnet_ids) == tolist(["subnet-default"]) - && local.experimental_config.compute_provider.ec2.runner_binaries.syncer.artifact.zip == "/private/tmp/forge-test-lambda-cache/runner-binaries-syncer.zip" + && local.experimental_config.compute_provider.aws.ec2.vpc_id == "vpc-test" + && tolist(local.experimental_config.compute_provider.aws.ec2.subnet_ids) == tolist(["subnet-default"]) + && local.experimental_config.compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip == "/private/tmp/forge-test-lambda-cache/runner-binaries-syncer.zip" && local.experimental_config.multi_runner_config == local.multi_runner_config ) error_message = "Forge must populate the authoritative global experimental contract." diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index 49ef1c8d..308f8b14 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -145,6 +145,7 @@ run "platform_ec2_deployment_interface_contract" { "denied = optional(list(string), [])", "max = optional(string, null)", "compute_provider = object({", + "aws = optional(object({", "ec2 = optional(object({", "metadata_options = optional(object({", "instance_metadata_tags = optional(string, \"enabled\")", @@ -247,9 +248,9 @@ run "platform_ec2_deployment_interface_contract" { "effective_runner_hooks = {", "textencodebase64(local.effective_runner_hooks[key].job_started, \"UTF-16LE\")", "base64encode(local.effective_runner_hooks[key].job_started)", - "length(runner_config.compute_provider.ec2[*]) == 1", - "length(runner_config.compute_provider.ec2.ami[*]) == 1", - "&& length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0,", + "length(runner_config.compute_provider.aws.ec2[*]) == 1", + "length(runner_config.compute_provider.aws.ec2.ami[*]) == 1", + "&& length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0,", "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.\"", "active_ec2_subnet_ids = toset(flatten([", "legacy_runner_labels = {", @@ -262,15 +263,15 @@ run "platform_ec2_deployment_interface_contract" { "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 = runner_config.compute_provider.aws.ec2.ami == null ? null : merge(", + "runner_config.compute_provider.aws.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)", + "runner_config.compute_provider.aws.ec2,", + "log_files = coalesce(runner_config.compute_provider.aws.ec2.log_files, local.forge_ec2_log_files[key])", + "tags = merge(var.tenant_configs.tags, runner_config.compute_provider.aws.ec2.tags)", "multi_runner_config = {", "key => merge(runner_config, {", "runner = merge(runner_config.runner, {", @@ -342,7 +343,7 @@ run "platform_ec2_deployment_interface_contract" { condition = ( output.expected_input_variable_count == 4 && output.expected_output_value_count == 6 - && output.expected_interface_literal_count == 288 + && output.expected_interface_literal_count == 289 ) 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 2141adca..1900c52e 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -18,6 +18,8 @@ run "platform_ec2_deployment_contract" { "app = var.runner_configs.github_app", "enterprise_server = {", "orchestration_provider = {", + "runner_config.compute_provider.aws.ec2,", + "ec2 = local.ec2_compute_provider[key]", "boot_time_in_minutes = optional(number, null)", "ephemeral = optional(bool, null)", "jit_config_enabled = optional(bool, null)", diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index bc08bdc3..46d461ac 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -211,125 +211,127 @@ variable "runner_configs" { }), {}) 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 + aws = optional(object({ + ec2 = optional(object({ + metadata_options = optional(object({ + instance_metadata_tags = optional(string, "enabled") + http_endpoint = optional(string, "enabled") + http_tokens = optional(string, "required") + http_put_response_hop_limit = optional(number, 1) + }), {}) + ami = optional(object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter = optional(object({ + arn = string + }), null) + kms_key = optional(object({ + arn = string + }), null) }), null) - 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") + })), [{ + volume_size = 30 + }]) + create_service_linked_role_spot = optional(bool, false) + credit_specification = optional(string, null) + ebs_optimized = optional(bool, false) + cloudwatch_agent = optional(object({ + enabled = optional(bool, true) + config = optional(string, null) + }), {}) + binaries_syncer = optional(object({ + enabled = optional(bool, null) + }), {}) + detailed_monitoring_enabled = optional(bool, false) + ssm_enabled = optional(bool, false) + user_data = optional(object({ + enabled = optional(bool, true) + template = optional(string, null) + content = optional(string, null) + pre_install = optional(string, "") + post_install = optional(string, "") + debug_logging_enabled = optional(bool, false) + }), {}) + instance_allocation_strategy = optional(string, "lowest-price") + instance_max_spot_price = optional(string, null) + instance_target_capacity_type = optional(string, "spot") + instance_type_priorities = optional(map(number), null) + instance_types = list(string) + additional_security_group_ids = optional(list(string), null) + managed_security_group_enabled = optional(bool, null) + egress_rules = optional(list(object({ + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + prefix_list_ids = list(string) + from_port = number + protocol = string + security_groups = list(string) + self = bool + to_port = number + description = string + })), null) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, null) + instance_profile = optional(object({ + name = string }), null) + 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) - block_device_mappings = optional(list(object({ - delete_on_termination = optional(bool, true) - device_name = optional(string, "/dev/xvda") - encrypted = optional(bool, true) - iops = optional(number) - kms_key_id = optional(string) - snapshot_id = optional(string) - throughput = optional(number) - volume_initialization_rate = optional(number) - volume_size = number - volume_type = optional(string, "gp3") - })), [{ - volume_size = 30 - }]) - create_service_linked_role_spot = optional(bool, false) - credit_specification = optional(string, null) - ebs_optimized = optional(bool, false) - cloudwatch_agent = optional(object({ - enabled = optional(bool, true) - config = optional(string, null) - }), {}) - binaries_syncer = optional(object({ - enabled = optional(bool, null) - }), {}) - detailed_monitoring_enabled = optional(bool, false) - ssm_enabled = optional(bool, false) - user_data = optional(object({ - enabled = optional(bool, true) - template = optional(string, null) - content = optional(string, null) - pre_install = optional(string, "") - post_install = optional(string, "") - debug_logging_enabled = optional(bool, false) - }), {}) - instance_allocation_strategy = optional(string, "lowest-price") - instance_max_spot_price = optional(string, null) - instance_target_capacity_type = optional(string, "spot") - instance_type_priorities = optional(map(number), null) - instance_types = list(string) - additional_security_group_ids = optional(list(string), null) - managed_security_group_enabled = optional(bool, null) - egress_rules = optional(list(object({ - cidr_blocks = list(string) - ipv6_cidr_blocks = list(string) - prefix_list_ids = list(string) - from_port = number - protocol = string - security_groups = list(string) - self = bool - to_port = number - description = string - })), null) - instance_profile_path = optional(string, null) - key_name = optional(string, null) - associate_public_ipv4_address = optional(bool, null) - instance_profile = optional(object({ - name = string - }), null) - 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) + }), {}) }) })) @@ -350,9 +352,9 @@ variable "runner_configs" { condition = alltrue([ for runner_config in values(var.runner_configs.runner_specs) : try( - length(runner_config.compute_provider.ec2[*]) == 1 - && length(runner_config.compute_provider.ec2.ami[*]) == 1 - && length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, + length(runner_config.compute_provider.aws.ec2[*]) == 1 + && length(runner_config.compute_provider.aws.ec2.ami[*]) == 1 + && length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0, false, ) ]) diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index 9abcdc51..c13bff69 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -20,7 +20,7 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived - The tenant `deployment_config` defines the GitHub App, runner group, repository selection, and IAM bridge. - EC2 and ARC runner configurations can be enabled for the same tenant; workflows choose by labels. - `ec2_deployment_specs.runner_specs` uses the full nested v2 shape; every entry - must configure `runner`, `orchestration_provider.webhook`, and `compute_provider.ec2`. + must configure `runner`, `orchestration_provider.webhook`, and `compute_provider.aws.ec2`. - `ec2_deployment` enriches that public shape with Forge-managed settings and passes it through upstream's `experimental.multi_runner_config`; shared GitHub API, common Lambda substrate, SSM KMS and housekeeper artifact, @@ -67,7 +67,7 @@ tag with the expected ref in `release_versions.yml`. ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | ~> 1.11 | | [archive](#requirement\_archive) | >= 2.7.0 | | [aws](#requirement\_aws) | >= 6.47 | @@ -80,16 +80,16 @@ tag with the expected ref in `release_versions.yml`. ## Providers | Name | Version | -|------|---------| -| [aws](#provider\_aws) | >= 6.47 | -| [null](#provider\_null) | >= 3.2 | -| [random](#provider\_random) | >= 3.6 | -| [time](#provider\_time) | >= 0.13 | +| ---- | ------- | +| [aws](#provider\_aws) | 6.60.0 | +| [null](#provider\_null) | 3.3.1 | +| [random](#provider\_random) | 3.9.0 | +| [time](#provider\_time) | 0.14.1 | ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [arc\_runners](#module\_arc\_runners) | ../arc_deployment | n/a | | [ec2\_runners](#module\_ec2\_runners) | ../ec2_deployment | n/a | | [forge\_trust\_validator](#module\_forge\_trust\_validator) | ./forge_trust_validator | n/a | @@ -102,7 +102,7 @@ tag with the expected ref in `release_versions.yml`. ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_iam_policy.ecr_access_for_ec2_instances](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_policy.role_assumption_for_forge_runners](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_servicecatalogappregistry_application.forge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/servicecatalogappregistry_application) | resource | @@ -127,13 +127,13 @@ tag with the expected ref in `release_versions.yml`. ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [arc\_deployment\_specs](#input\_arc\_deployment\_specs) | Deployment configuration for Azure Container Apps (ARC) runners.

Top-level fields:
- cluster\_name : Name of the EKS cluster used for ARC runners.
- migrate\_cluster: Optional flag to indicate a one-time migration or
blue/green cutover of the ARC runner cluster.
- runner\_specs : Map of ARC runner pool keys to their sizing and
container resource settings.

runner\_specs[*] object fields:
- runner\_size.max\_runners: Maximum concurrent ARC runners for this pool.
- runner\_size.min\_runners: Minimum number of warm runners.
- scale\_set\_name : Logical name for the scale set / pool.
- scale\_set\_type : Backing type for the scale set (for example,
kubernetes or containerapp, depending on integration).
- scale\_set\_labels : GitHub runner labels advertised by this ARC
scale set.
- container\_images : Container images used by the ARC runner,
sidecars, and DinD containers.
- container\_limits\_cpu : CPU limit for the runner container.
- container\_limits\_memory : Memory limit for the runner container.
- container\_requests\_cpu : CPU request (baseline reservation).
- container\_requests\_memory : Memory request (baseline reservation).
- volume\_requests\_storage\_size: Size of attached storage for the runner.
- volume\_requests\_storage\_type: Storage class or type for attached volume. |
object({
cluster_name = string
migrate_cluster = optional(bool, false)
runner_specs = map(object({
runner_size = object({
max_runners = number
min_runners = number
})
scale_set_name = string
scale_set_type = string
scale_set_labels = list(string)
container_images = optional(object({
actions_runner = optional(string, "ghcr.io/actions/actions-runner:latest")
busybox = optional(string, "public.ecr.aws/docker/library/busybox:stable")
dind_rootless = optional(string, "public.ecr.aws/docker/library/docker:dind-rootless")
}), {})
container_limits_cpu = string
container_limits_memory = string
container_requests_cpu = string
container_requests_memory = string
volume_requests_storage_size = string
volume_requests_storage_type = string
}))
})
| n/a | yes | | [aws\_profile](#input\_aws\_profile) | AWS profile to use. | `string` | n/a | yes | | [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. The public runner
shape follows the nested experimental multi\_runner\_config EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 configurations.

runner\_specs[*] object fields:
- runner : Provider-neutral OS, architecture, labels, bootstrap,
hooks, and IAM-policy configuration.
- lambda : Provider-neutral per-configuration Lambda runtime,
network, role, and tag overrides.
- orchestration\_provider : Exactly one demand-controller provider. The webhook
provider owns runner lifecycle, capacity, and startup
timing, GitHub scope, matching, queues, scaling, pools,
and job retry.
- ssm : Per-configuration paths, tags, parameter tags, and
housekeeper settings, including its Lambda artifact.
- observability : Per-configuration logging, tracing, and metrics overrides.
- compute\_provider : Nested v2 EC2 provider configuration.
- tags : Per-configuration resource tags.

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/security-group fields: Per-configuration networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

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, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
})

}))
})
| n/a | yes | +| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | EC2 deployment configuration for GitHub Actions runners. The public runner
shape follows the nested experimental multi\_runner\_config EC2 contract and
is passed directly to the upstream provider-oriented runner stack.

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 configurations.

runner\_specs[*] object fields:
- runner : Provider-neutral OS, architecture, labels, bootstrap,
hooks, and IAM-policy configuration.
- lambda : Provider-neutral per-configuration Lambda runtime,
network, role, and tag overrides.
- orchestration\_provider : Exactly one demand-controller provider. The webhook
provider owns runner lifecycle, capacity, and startup
timing, GitHub scope, matching, queues, scaling, pools,
and job retry.
- ssm : Per-configuration paths, tags, parameter tags, and
housekeeper settings, including its Lambda artifact.
- observability : Per-configuration logging, tracing, and metrics overrides.
- compute\_provider : Nested v2 EC2 provider configuration.
- tags : Per-configuration resource tags.

compute\_provider.aws.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/security-group fields: Per-configuration networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- 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({
tags = optional(map(string), {})

runner = object({
os = string
architecture = string
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = object({
webhook = optional(object({
runner = object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = number
})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, null)
enable_job_retry = optional(bool, null)
}), {})
}), {})
}), {})

compute_provider = object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
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)
}), {})
})

}))
})
| 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 | @@ -142,7 +142,7 @@ tag with the expected ref in `release_versions.yml`. ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [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. | diff --git a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl index 6a854756..1ba66eea 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -241,6 +241,7 @@ run "platform_forge_runners_interface_contract" { "denied = optional(list(string), [])", "max = optional(string, null)", "compute_provider = object({", + "aws = optional(object({", "ec2 = optional(object({", "metadata_options = optional(object({", "instance_metadata_tags = optional(string, \"enabled\")", @@ -332,9 +333,9 @@ run "platform_forge_runners_interface_contract" { "for provider_config in values(runner_config.orchestration_provider) : provider_config", "if provider_config != null", "error_message = \"Each Forge runner configuration must select exactly one non-null orchestration provider.\"", - "length(runner_config.compute_provider.ec2[*]) == 1", - "length(runner_config.compute_provider.ec2.ami[*]) == 1", - "&& length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0,", + "length(runner_config.compute_provider.aws.ec2[*]) == 1", + "length(runner_config.compute_provider.aws.ec2.ami[*]) == 1", + "&& length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0,", "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.\"", "EC2 deployment configuration for GitHub Actions runners. The public runner", "- lambda_subnet_ids: Subnets where runner-related lambdas execute.", @@ -357,7 +358,7 @@ run "platform_forge_runners_interface_contract" { "- observability : Per-configuration logging, tracing, and metrics overrides.", "- compute_provider : Nested v2 EC2 provider configuration.", "- tags : Per-configuration resource tags.", - "compute_provider.ec2 fields:", + "compute_provider.aws.ec2 fields:", "- ami : Upstream-compatible EC2 AMI configuration.", "- instance_profile: Optional externally managed instance profile. It requires", "supplies Forge's EC2-tag and hook-SSM permissions.", @@ -447,7 +448,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 == 388 + && output.expected_interface_literal_count == 389 ) 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 f2851879..a5797fa3 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -35,6 +35,7 @@ run "platform_forge_runners_contract" { "job_retry = optional(object({", "artifact = optional(object({", "compute_provider = object({", + "aws = optional(object({", "ec2 = optional(object({", "matcherConfig = object({", "scale_errors = optional(list(string), [", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index 58b6c0cb..33a02af9 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -209,125 +209,127 @@ variable "ec2_deployment_specs" { }), {}) 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 + aws = optional(object({ + ec2 = optional(object({ + metadata_options = optional(object({ + instance_metadata_tags = optional(string, "enabled") + http_endpoint = optional(string, "enabled") + http_tokens = optional(string, "required") + http_put_response_hop_limit = optional(number, 1) + }), {}) + ami = optional(object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter = optional(object({ + arn = string + }), null) + kms_key = optional(object({ + arn = string + }), null) }), null) - 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") + })), [{ + volume_size = 30 + }]) + create_service_linked_role_spot = optional(bool, false) + credit_specification = optional(string, null) + ebs_optimized = optional(bool, false) + cloudwatch_agent = optional(object({ + enabled = optional(bool, true) + config = optional(string, null) + }), {}) + binaries_syncer = optional(object({ + enabled = optional(bool, null) + }), {}) + detailed_monitoring_enabled = optional(bool, false) + ssm_enabled = optional(bool, false) + user_data = optional(object({ + enabled = optional(bool, true) + template = optional(string, null) + content = optional(string, null) + pre_install = optional(string, "") + post_install = optional(string, "") + debug_logging_enabled = optional(bool, false) + }), {}) + instance_allocation_strategy = optional(string, "lowest-price") + instance_max_spot_price = optional(string, null) + instance_target_capacity_type = optional(string, "spot") + instance_type_priorities = optional(map(number), null) + instance_types = list(string) + additional_security_group_ids = optional(list(string), null) + managed_security_group_enabled = optional(bool, null) + egress_rules = optional(list(object({ + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + prefix_list_ids = list(string) + from_port = number + protocol = string + security_groups = list(string) + self = bool + to_port = number + description = string + })), null) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, null) + instance_profile = optional(object({ + name = string }), null) + 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) - block_device_mappings = optional(list(object({ - delete_on_termination = optional(bool, true) - device_name = optional(string, "/dev/xvda") - encrypted = optional(bool, true) - iops = optional(number) - kms_key_id = optional(string) - snapshot_id = optional(string) - throughput = optional(number) - volume_initialization_rate = optional(number) - volume_size = number - volume_type = optional(string, "gp3") - })), [{ - volume_size = 30 - }]) - create_service_linked_role_spot = optional(bool, false) - credit_specification = optional(string, null) - ebs_optimized = optional(bool, false) - cloudwatch_agent = optional(object({ - enabled = optional(bool, true) - config = optional(string, null) - }), {}) - binaries_syncer = optional(object({ - enabled = optional(bool, null) - }), {}) - detailed_monitoring_enabled = optional(bool, false) - ssm_enabled = optional(bool, false) - user_data = optional(object({ - enabled = optional(bool, true) - template = optional(string, null) - content = optional(string, null) - pre_install = optional(string, "") - post_install = optional(string, "") - debug_logging_enabled = optional(bool, false) - }), {}) - instance_allocation_strategy = optional(string, "lowest-price") - instance_max_spot_price = optional(string, null) - instance_target_capacity_type = optional(string, "spot") - instance_type_priorities = optional(map(number), null) - instance_types = list(string) - additional_security_group_ids = optional(list(string), null) - managed_security_group_enabled = optional(bool, null) - egress_rules = optional(list(object({ - cidr_blocks = list(string) - ipv6_cidr_blocks = list(string) - prefix_list_ids = list(string) - from_port = number - protocol = string - security_groups = list(string) - self = bool - to_port = number - description = string - })), null) - instance_profile_path = optional(string, null) - key_name = optional(string, null) - associate_public_ipv4_address = optional(bool, null) - instance_profile = optional(object({ - name = string - }), null) - 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) + }), {}) }) })) @@ -348,9 +350,9 @@ variable "ec2_deployment_specs" { condition = alltrue([ for runner_config in values(var.ec2_deployment_specs.runner_specs) : try( - length(runner_config.compute_provider.ec2[*]) == 1 - && length(runner_config.compute_provider.ec2.ami[*]) == 1 - && length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, + length(runner_config.compute_provider.aws.ec2[*]) == 1 + && length(runner_config.compute_provider.aws.ec2.ami[*]) == 1 + && length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0, false, ) ]) @@ -384,7 +386,7 @@ variable "ec2_deployment_specs" { - compute_provider : Nested v2 EC2 provider configuration. - tags : Per-configuration resource tags. - compute_provider.ec2 fields: + compute_provider.aws.ec2 fields: - ami : Upstream-compatible EC2 AMI configuration. Forge requires a module-managed AMI block; null and external AMI parameter ownership are unsupported.