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..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 @@ -73,118 +73,131 @@ 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 } - github = { - organization_runners = true - } - 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) - } - 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 = { - http_endpoint = "enabled" - http_put_response_hop_limit = 2 - http_tokens = "optional" - instance_metadata_tags = "enabled" + orchestration_provider = { + webhook = { + runner = { + maximum_count = spec.max_instances + ephemeral = true } - ami = { - filter = { - name = [spec.ami_name] - state = ["available"] + 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) + } + } + down = { + minimum_running_time_in_minutes = 30 + } } - owners = [spec.ami_owner] - kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { - arn = spec.ami_kms_key_arn + pool = { + config = spec.pool_config + runner_owner = local.config.gh_config.ghes_org } } - create_service_linked_role_spot = true - cloudwatch_agent = { - enabled = true - } - binaries_syncer = { - enabled = false + 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) + } } - detailed_monitoring_enabled = true - ssm_enabled = true - user_data = { - enabled = true - pre_install = "# No pre-install steps." + 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) } - 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 - }] } } - 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) + compute_provider = { + aws = { + 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"] + } + 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 + }] + } + } } } } diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index 50815a3c..46167e4e 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -73,118 +73,131 @@ 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 } - github = { - organization_runners = true - } - 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) - } - 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 = { - http_endpoint = "enabled" - http_put_response_hop_limit = 2 - http_tokens = "optional" - instance_metadata_tags = "enabled" + orchestration_provider = { + webhook = { + runner = { + maximum_count = spec.max_instances + ephemeral = true } - ami = { - filter = { - name = [spec.ami_name] - state = ["available"] + 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) + } + } + down = { + minimum_running_time_in_minutes = 30 + } } - owners = [spec.ami_owner] - kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { - arn = spec.ami_kms_key_arn + pool = { + config = spec.pool_config + runner_owner = local.config.gh_config.ghes_org } } - create_service_linked_role_spot = true - cloudwatch_agent = { - enabled = true - } - binaries_syncer = { - enabled = false + 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) + } } - detailed_monitoring_enabled = true - ssm_enabled = true - user_data = { - enabled = true - pre_install = "# No pre-install steps." + 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) } - 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 - }] } } - 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) + compute_provider = { + aws = { + 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"] + } + 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 + }] + } + } } } } diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index d73a95a7..efa05732 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -5,42 +5,59 @@ 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 experimental input lets each runner configuration select a typed +orchestration provider and a typed compute provider. Webhook orchestration owns +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 +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`. 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. + nested v2 shape and must contain `runner`, `orchestration_provider.webhook`, and + `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 + that provider block. The legacy flat EC2 shape is not accepted. +- The runner-control archive is selected once at + `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 + 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 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 + 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,8 +76,8 @@ dedicated hosts, and larger hardware profiles. | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | -| [external](#provider\_external) | 2.4.0 | +| [aws](#provider\_aws) | 6.60.0 | +| [external](#provider\_external) | 2.4.1 | ## Modules @@ -68,7 +85,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 @@ -97,7 +114,7 @@ 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
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
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 677228a9..7c653508 100644
--- a/modules/platform/ec2_deployment/main.tf
+++ b/modules/platform/ec2_deployment/main.tf
@@ -125,47 +125,15 @@ 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
- 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
+ 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"
-
- # 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 = local.experimental_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..72efd422 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(
@@ -37,23 +45,23 @@ 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 => 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],
])
}
@@ -65,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
@@ -83,8 +91,8 @@ locals {
legacy_runner_labels = {
for key, runner_config in local.ec2_runner_configs :
key => concat(
- try(runner_config.matcherConfig.labelMatchers[0], []),
- runner_config.runner.extra_labels,
+ try(runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers[0], []),
+ coalesce(runner_config.runner.extra_labels, []),
)
}
@@ -97,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_provider.webhook.matcherConfig.labelMatchers : labels if matcher_index > 0
]) : label
if !contains(local.legacy_runner_labels[key], label)
]),
@@ -141,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,
{
@@ -172,110 +180,121 @@ 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)
}
)
}
- # 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)
+ 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]
+ }
+ iam = merge(runner_config.runner.iam, {
+ managed_policy_arns = merge(
+ 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 :
+ "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 = {
+ aws = {
+ ec2 = local.ec2_compute_provider[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
+ }
+ }
- 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
+ lambda = {
+ 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
+ }
- 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
+ orchestration_provider = {
+ webhook = {
+ eventbridge = {
+ enable = true
}
+ lambda = {
+ 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
+ }
+ }
+ }
+ }
+ }
- 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]
- ],
- )
+ ssm = {
+ kms_key_id = aws_kms_key.github.arn
+ parameters = {
+ tags = local.terraform_aws_github_runner_tags
+ }
+ housekeeper = {
+ lambda = {
+ artifact = {
+ zip = "${data.external.download_lambdas.result.path}/runners.zip"
+ }
+ }
}
+ }
- matcherConfig = runner_config.matcherConfig
+ observability = {
+ logs = {
+ level = lower(var.runner_configs.log_level)
+ retention_in_days = tonumber(var.runner_configs.logging_retention_in_days)
+ }
+ }
- redrive_build_queue = runner_config.queue.redrive_build_queue
+ compute_provider = {
+ 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"
+ }
+ }
+ }
+ }
+ }
}
+
+ 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 63eaa876..847b3c44 100644
--- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl
+++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl
@@ -126,20 +126,18 @@ variables {
runner_iam_role_managed_policy_arns = []
runner_specs = {
ec2 = {
+ tags = { Scope = "entry" }
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"
- maximum_count = 2
- 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,117 +146,256 @@ 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
- }
- queue = {
- delay_webhook_event = 7
- job_queue_retention_in_seconds = 90000
- event_source_mapping = {
- batch_size = 5
- maximum_batching_window_in_seconds = 1
- }
- redrive_build_queue = {
- enabled = true
- maxReceiveCount = 4
+ 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 = {
- reserved_concurrent_executions = 2
- job_queued_check_enabled = false
- }
- scale_down = {
- schedule_expression = "rate(10 minutes)"
- minimum_running_time_in_minutes = 5
- idle_config = [{
- cron = "* * * * *"
- timeZone = "Europe/Warsaw"
- idleCount = 1
- evictionStrategy = "newest_first"
- }]
+
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ 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
+ }
+ 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" }
+ }
+ }
+ 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"]
+ }
+ }
+ }
}
- pool = {
- config = [{
- schedule_expression = "cron(0 8 * * ? *)"
- schedule_expression_timezone = "Europe/Warsaw"
- size = 1
- }]
- runner_owner = "cisco-open"
+ ssm = {
+ tags = { Scope = "ssm" }
+ paths = {
+ root = "/custom-root"
+ tokens = "custom-tokens"
+ config = "custom-config"
+ }
+ parameters = {
+ tags = { Scope = "ssm-parameters" }
+ }
+ housekeeper = {
+ schedule_expression = "rate(12 hours)"
+ state = "DISABLED"
+ tags = { Scope = "ssm-housekeeper" }
+ lambda = {
+ artifact = {
+ zip = "/private/tmp/forge-test-lambda-cache/ssm-housekeeper.zip"
+ }
+ memory_size = 384
+ timeout = 70
+ }
+ config = {
+ tokenPath = "custom-token-path"
+ minimumDaysOld = 2
+ dryRun = true
+ }
+ }
}
- job_retry = {
- enabled = true
- delay_in_seconds = 120
- delay_backoff = 3
- max_attempts = 2
- lambda = {
- memory_size = 512
- timeout = 45
+ observability = {
+ 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 = {
- 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
+ }
+ }
+ }
+ default_path = {
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ maximum_count = 1
}
- detailed_monitoring_enabled = true
- ebs_optimized = true
- instance_allocation_strategy = "prioritized"
- instance_type_priorities = { "m7i.large" = 1 }
- instance_types = ["m7i.large"]
- instance_target_capacity_type = "on-demand"
- additional_security_group_ids = ["sg-runner"]
- scale_errors = ["InsufficientInstanceCapacity"]
- ssm_enabled = true
- subnet_ids = ["subnet-override"]
- tags = { Lane = "ec2" }
- user_data = {
- enabled = true
- pre_install = "caller-pre"
- post_install = "caller-post"
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "default-path"]]
}
- 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"
- }]
}
}
- matcherConfig = {
- labelMatchers = [["self-hosted", "ec2"], ["self-hosted", "gpu"]]
- exactMatch = true
- bidirectionalLabelMatch = true
- priority = 5
- enableDynamicLabels = true
- awsDynamicLabelsPolicy = {
- blocked_keys = ["instance-type"]
+ compute_provider = {
+ aws = {
+ ec2 = {
+ ami = {}
+ instance_types = ["m7i.large"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
}
}
}
@@ -266,7 +403,7 @@ variables {
}
}
-run "ec2_v2_input_v1_adapter_plan" {
+run "ec2_v2_input_plan" {
command = plan
plan_options {
@@ -277,104 +414,144 @@ run "ec2_v2_input_v1_adapter_plan" {
}
assert {
- condition = toset(keys(local.ec2_runner_configs)) == toset(["ec2"])
- error_message = "EC2 provider filtering must retain every EC2 lane."
+ condition = toset(keys(local.ec2_runner_configs)) == toset(["default_path", "ec2"])
+ 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 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 = toset(keys(local.multi_runner_config_v1)) == toset(["ec2"])
- error_message = "The stable v1 adapter must preserve every EC2 lane key."
+ condition = alltrue([
+ for runner_config in values(local.multi_runner_config) :
+ length([
+ for provider_config in values(runner_config.orchestration_provider) : 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-override"])
- error_message = "EC2 effective subnet resolution must preserve per-lane overrides."
+ condition = local.active_ec2_subnet_ids == toset(["subnet-default", "subnet-override"])
+ error_message = "EC2 effective subnet resolution must preserve per-configuration overrides."
}
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.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 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.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 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.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_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_provider.webhook.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 common runner and webhook-owned 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.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 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.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 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 = !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."
}
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.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 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 +566,143 @@ 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.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.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.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.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 = "Caller hooks must be isolated from Forge's required lifecycle hooks, and caller policies must be appended."
+ error_message = "The v2 configuration must preserve matcher configuration."
}
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"])
+ 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_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"
+ && local.multi_runner_config.ec2.observability.logs.tags.Scope == "logs"
)
- error_message = "The v1 adapter must preserve matcher configuration."
+ error_message = "The v2 configuration must preserve every component tag scope."
}
+
+ assert {
+ condition = (
+ 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 runner-configuration 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 runner-configuration 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.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 runner-configuration 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 runner-configuration observability overrides."
+ }
+
+ assert {
+ condition = (
+ local.experimental_config.github.app.id == "12345"
+ && local.experimental_config.github.enterprise_server.url == null
+ && 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_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"
+ && local.experimental_config.observability.logs.retention_in_days == 3
+ && 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."
+ }
+
+ 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.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 297fd481..308f8b14 100644
--- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl
+++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl
@@ -47,43 +47,53 @@ 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\")",
- "maximum_count = number",
- "ephemeral = optional(bool, false)",
- "jit_config_enabled = optional(bool, null)",
- "auto_update_disabled = optional(bool, false)",
+ "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_provider = object({",
+ "webhook = optional(object({",
+ "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, \"\")",
- "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",
- "scale_up = optional(object({",
- "reserved_concurrent_executions = optional(number, 1)",
+ "enabled = optional(bool, null)",
+ "maxReceiveCount = optional(number, null)",
+ "scale = optional(object({",
+ "up = optional(object({",
+ "reserved_concurrent_executions = optional(number, null)",
"job_queued_check_enabled = optional(bool, null)",
- "scale_down = optional(object({",
- "schedule_expression = optional(string, \"cron(*/5 * * * ? *)\")",
+ "down = optional(object({",
+ "schedule_expression = optional(string, null)",
"minimum_running_time_in_minutes = optional(number, null)",
"idle_config = optional(list(object({",
"cron = string",
@@ -95,7 +105,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,17 +115,37 @@ 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({",
+ "artifact = optional(object({",
+ "zip = optional(string, null)",
+ "object_version = optional(string, null)",
+ "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), [])",
"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\")",
@@ -153,12 +184,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), [])",
@@ -198,6 +234,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) : 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)",
@@ -206,27 +245,17 @@ 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)",
- "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,",
+ "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.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.\"",
- "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",
+ "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-*\"] }",
@@ -234,42 +263,45 @@ 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)",
- "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",
+ "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, {",
+ "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]",
"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]",
+ "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 = {}",
+ "experimental = local.experimental_config",
+ "experimental_config = {",
+ "app = var.runner_configs.github_app",
+ "enterprise_server = {",
+ "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",
+ "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 : 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 +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 == 257
+ && 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 30b83268..1900c52e 100644
--- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl
+++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl
@@ -11,13 +11,35 @@ 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\"",
- "multi_runner_config = local.multi_runner_config_v1",
+ "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 = {}",
+ "experimental = local.experimental_config",
+ "experimental_config = {",
+ "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)",
+ "maximum_count = number",
+ "scale = optional(object({",
+ "up = optional(object({",
+ "down = optional(object({",
+ "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\"",
"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 e0044ac7..46d461ac 100644
--- a/modules/platform/ec2_deployment/variables.tf
+++ b/modules/platform/ec2_deployment/variables.tf
@@ -22,306 +22,345 @@ 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")
- maximum_count = number
- ephemeral = optional(bool, false)
- jit_config_enabled = optional(bool, null)
- auto_update_disabled = optional(bool, false)
+ 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, "")
- 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)
}), {})
})
- 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)
+ 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)
}), {})
- 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), {})
- }), {})
+ 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
+ })
- 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")
- })), [])
- }), {})
+ github = optional(object({
+ organization_runners = optional(bool, false)
+ }), {})
- 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), {})
- }), {})
+ 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)
+ })
- 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)
- }), {})
- }), {})
+ 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), {})
- 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({
+ 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({
- 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)
+ }), {})
}), {})
}), {})
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, 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)
- })
}))
})
validation {
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,
- false,
- )
+ length([
+ for provider_config in values(runner_config.orchestration_provider) : provider_config
+ if provider_config != null
+ ]) == 1
])
- 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."
+ 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) :
try(
- length(runner_config.compute_provider.ec2.instance_profile[*]) == 0
- && length(runner_config.runner.iam.role[*]) == 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,
)
])
- 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."
+ error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported."
}
- validation {
- condition = alltrue([
- for runner_config in values(var.runner_configs.runner_specs) :
- runner_config.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..c13bff69 100644
--- a/modules/platform/forge_runners/README.md
+++ b/modules/platform/forge_runners/README.md
@@ -18,12 +18,26 @@ 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`.
-- `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.
+ 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,
+ observability, and EC2 network settings are populated in the experimental
+ global blocks.
+- Webhook runner lifecycle, capacity, startup timing, GitHub scope, matching,
+ queue, scale-up, scale-down, pool, and retry settings are nested below
+ `orchestration_provider.webhook`; the common per-configuration `lambda` block remains
+ orchestration-provider-neutral.
+- The shared runner-control archive is selected globally at
+ `orchestration_provider.webhook.lambda.artifact`; per-runner webhook Lambda blocks do
+ not select deployment artifacts.
+- 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,10 +81,10 @@ tag with the expected ref in `release_versions.yml`.
| Name | Version |
| ---- | ------- |
-| [aws](#provider\_aws) | 6.58.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
@@ -119,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.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 runnerobject({
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 runnerobject({
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.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..1ba66eea 100644
--- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl
+++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl
@@ -143,43 +143,53 @@ 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\")",
- "maximum_count = number",
- "ephemeral = optional(bool, false)",
- "jit_config_enabled = optional(bool, null)",
- "auto_update_disabled = optional(bool, false)",
+ "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_provider = object({",
+ "webhook = optional(object({",
+ "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, \"\")",
- "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",
- "scale_up = optional(object({",
- "reserved_concurrent_executions = optional(number, 1)",
+ "enabled = optional(bool, null)",
+ "maxReceiveCount = optional(number, null)",
+ "scale = optional(object({",
+ "up = optional(object({",
+ "reserved_concurrent_executions = optional(number, null)",
"job_queued_check_enabled = optional(bool, null)",
- "scale_down = optional(object({",
- "schedule_expression = optional(string, \"cron(*/5 * * * ? *)\")",
+ "down = optional(object({",
+ "schedule_expression = optional(string, null)",
"minimum_running_time_in_minutes = optional(number, null)",
"idle_config = optional(list(object({",
"cron = string",
@@ -191,7 +201,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,17 +211,37 @@ 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({",
+ "artifact = optional(object({",
+ "zip = optional(string, null)",
+ "object_version = optional(string, null)",
+ "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), [])",
"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\")",
@@ -249,12 +280,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), [])",
@@ -294,37 +330,38 @@ run "platform_forge_runners_interface_contract" {
"enableDynamicLabels = optional(bool, false)",
"awsDynamicLabelsPolicy = optional(object({",
"for runner_config in values(var.ec2_deployment_specs.runner_specs) :",
- "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,",
+ "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.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.\"",
- "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.",
"- 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 : 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.",
- "- compute_provider: Nested v2-compatible EC2 provider configuration.",
- "- tags/lambda/ssm/observability: Included for v2 contract compatibility.",
- "compute_provider.ec2 fields:",
+ "- runner_specs : Map of EC2 runner configurations.",
+ "- 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_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.",
- "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 +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 == 352
+ && 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 996fecf7..a5797fa3 100644
--- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl
+++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl
@@ -19,12 +19,23 @@ run "platform_forge_runners_contract" {
"resource \"random_id\" \"random\"",
"runner_specs = var.ec2_deployment_specs.runner_specs",
"runner = object({",
+ "orchestration_provider = object({",
+ "webhook = optional(object({",
+ "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({",
- "scale_up = optional(object({",
- "scale_down = optional(object({",
+ "visibility_timeout_seconds = optional(number, null)",
+ "scale = optional(object({",
+ "up = optional(object({",
+ "down = optional(object({",
"pool = optional(object({",
"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 c765dd8b..33a02af9 100644
--- a/modules/platform/forge_runners/variables.tf
+++ b/modules/platform/forge_runners/variables.tf
@@ -20,354 +20,386 @@ 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")
- maximum_count = number
- ephemeral = optional(bool, false)
- jit_config_enabled = optional(bool, null)
- auto_update_disabled = optional(bool, false)
+ 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, "")
- 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)
}), {})
})
- 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)
+ 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)
}), {})
- 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), {})
- }), {})
+ 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)
+ }), {})
- 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")
- })), [])
- }), {})
+ 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), {})
+ }), {})
- 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), {})
- }), {})
+ 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)
- }), {})
- }), {})
+ 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), {})
- 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({
+ 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({
- 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)
+ }), {})
}), {})
}), {})
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, 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)
- })
}))
})
validation {
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,
- false,
- )
+ length([
+ for provider_config in values(runner_config.orchestration_provider) : provider_config
+ if provider_config != null
+ ]) == 1
])
- 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."
+ 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) :
try(
- length(runner_config.compute_provider.ec2.instance_profile[*]) == 0
- && length(runner_config.runner.iam.role[*]) == 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,
)
])
- 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."
+ 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."
}
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 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 : 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.
- - 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:
+ - 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.
- 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.
+ - vpc_id/subnet_ids/security-group fields: Per-configuration 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
}
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',
],