diff --git a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl index 448f429fb..27b4ed1b4 100644 --- a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl +++ b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl @@ -63,12 +63,6 @@ locals { ec2_runner_specs = { for size, spec in local.config.ec2_runner_specs : size => { - ami_filter = { - name = [spec.ami_name], - state = ["available"], - } - ami_owners = [spec.ami_owner] - ami_kms_key_arn = spec.ami_kms_key_arn runner_os = spec.runner_os runner_architecture = spec.runner_architecture runner_labels = [ @@ -83,36 +77,69 @@ locals { "vpc:${local.vpc_alias}", "tnt:${local.tenant_name}", ] - enable_userdata = true enable_dynamic_labels = try(spec.enable_dynamic_labels, false) aws_dynamic_labels_policy = try(spec.aws_dynamic_labels_policy, null) lambda_event_source_mapping_batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) lambda_event_source_mapping_maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) redrive_build_queue = try(spec.redrive_build_queue, {}) runner_user = spec.runner_user - instance_target_capacity_type = "on-demand" min_run_time = 30 max_instances = spec.max_instances - instance_types = spec.instance_types - placement = try(spec.placement, null) - license_specifications = try(spec.license_specifications, null) - use_dedicated_host = try(spec.use_dedicated_host, false) - vpc_id = try(spec.vpc_id, null) - subnet_ids = try(spec.subnet_ids, null) - block_device_mappings = [{ - delete_on_termination = true - device_name = spec.volume.device_name - encrypted = true - iops = spec.volume.iops - kms_key_id = null - snapshot_id = null - throughput = spec.volume.throughput - volume_size = spec.volume.size - volume_type = spec.volume.type - }] - pool_config = spec.pool_config + pool_config = spec.pool_config + compute_provider = { + ec2 = { + 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 + } + 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 + }] + } + } } } + arc_cluster_name = local.config.arc_cluster_name migrate_arc_cluster = local.config.migrate_arc_cluster diff --git a/examples/deployments/platform/terragrunt/environments/terragrunt.hcl b/examples/deployments/platform/terragrunt/environments/terragrunt.hcl index 54b69831f..f07c1d422 100644 --- a/examples/deployments/platform/terragrunt/environments/terragrunt.hcl +++ b/examples/deployments/platform/terragrunt/environments/terragrunt.hcl @@ -1,5 +1,5 @@ locals { - github_runner_module_source = get_env("GITHUB_RUNNER_MODULE_SOURCE", "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=v7.8.0") + github_runner_module_source = get_env("GITHUB_RUNNER_MODULE_SOURCE", "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=v7.10.1") } generate "github_runner_source_override" { diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index 448f429fb..27b4ed1b4 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -63,12 +63,6 @@ locals { ec2_runner_specs = { for size, spec in local.config.ec2_runner_specs : size => { - ami_filter = { - name = [spec.ami_name], - state = ["available"], - } - ami_owners = [spec.ami_owner] - ami_kms_key_arn = spec.ami_kms_key_arn runner_os = spec.runner_os runner_architecture = spec.runner_architecture runner_labels = [ @@ -83,36 +77,69 @@ locals { "vpc:${local.vpc_alias}", "tnt:${local.tenant_name}", ] - enable_userdata = true enable_dynamic_labels = try(spec.enable_dynamic_labels, false) aws_dynamic_labels_policy = try(spec.aws_dynamic_labels_policy, null) lambda_event_source_mapping_batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) lambda_event_source_mapping_maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) redrive_build_queue = try(spec.redrive_build_queue, {}) runner_user = spec.runner_user - instance_target_capacity_type = "on-demand" min_run_time = 30 max_instances = spec.max_instances - instance_types = spec.instance_types - placement = try(spec.placement, null) - license_specifications = try(spec.license_specifications, null) - use_dedicated_host = try(spec.use_dedicated_host, false) - vpc_id = try(spec.vpc_id, null) - subnet_ids = try(spec.subnet_ids, null) - block_device_mappings = [{ - delete_on_termination = true - device_name = spec.volume.device_name - encrypted = true - iops = spec.volume.iops - kms_key_id = null - snapshot_id = null - throughput = spec.volume.throughput - volume_size = spec.volume.size - volume_type = spec.volume.type - }] - pool_config = spec.pool_config + pool_config = spec.pool_config + compute_provider = { + ec2 = { + 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 + } + 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 + }] + } + } } } + arc_cluster_name = local.config.arc_cluster_name migrate_arc_cluster = local.config.migrate_arc_cluster diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index 2c5a4e123..ed38f2d2f 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -1,23 +1,41 @@ # EC2 Runner Deployment -This module deploys Forge EC2 runner pools using the upstream `terraform-aws-github-runner` multi-runner module. +This module deploys Forge EC2 runner pools through the upstream +`terraform-aws-github-runner` multi-runner module. ## Why This Module Exists -The EC2 lane gives a GitHub Actions job a full VM or dedicated host. Forge uses it for workloads that need VM-level control, custom AMIs, macOS/Windows, larger hardware, or stronger isolation than a normal pod can provide. +The nested, v2-compatible EC2 input lets each runner lane configure the +upstream EC2 provider object while sharing Forge's webhook and runner control +plane. Forge currently translates that input to the released v1 +`multi_runner_config`, so this phase does not enable upstream's experimental v2 +runtime. EC2 supports custom AMIs, macOS/Windows, dedicated hosts, and larger +hardware profiles. ## What It Manages - The upstream multi-runner control plane for webhook, scale-up, scale-down, and ephemeral runner registration. -- Per-runner-pool label matching, AMI selection, instance types, warm pool schedules, and capacity type. -- KMS key material, Lambda egress security group, runner tags policy, and logging hooks. -- Supporting modules that update runner tags and runner AMI SSM parameters. +- Per-lane label matching, 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 -- Every EC2 runner is ephemeral; the instance is expected to register for one job and then be reaped. +- This is a breaking input migration: every `runner_specs` entry must contain + `compute_provider.ec2`. The legacy flat EC2 shape is not accepted. +- The EC2 block mirrors the upstream v2 nested contract and is adapted to the + stable upstream v1 fields. Forge still owns AMI + refresh and runner instance profiles, so EC2 lanes require a non-null, + module-managed `ami` block and cannot select `ami.id_ssm_parameter` or an + external `instance_profile`. The scheduled refresh uses the same default AMI + name filter as the upstream EC2 provider for each runner OS and architecture; + values in `ami.filter` override those defaults. +- Stable v1 has no per-lane user-data debug flag, so + `user_data.debug_logging_enabled` must remain `false` during this adapter + phase. +- Every EC2 runner is ephemeral and is expected to register for one job and then be reaped. - Label sets are the API contract with tenant workflows, so exact matching matters. -- Cold starts can take minutes; use warm pools only where latency justifies the idle cost. +- 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. @@ -34,7 +52,7 @@ The EC2 lane gives a GitHub Actions job a full VM or dedicated host. Forge uses | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.57.1 | +| [aws](#provider\_aws) | 6.58.0 | | [external](#provider\_external) | 2.4.0 | ## Modules @@ -72,7 +90,7 @@ The EC2 lane gives a GitHub Actions job a full VM or dedicated host. Forge uses | ---- | ----------- | ---- | ------- | :------: | | [aws\_region](#input\_aws\_region) | Assuming single region for now. | `string` | n/a | yes | | [network\_configs](#input\_network\_configs) | n/a |
object({
vpc_id = string
subnet_ids = list(string)
lambda_vpc_id = string
lambda_subnet_ids = list(string)
}) | n/a | yes |
-| [runner\_configs](#input\_runner\_configs) | n/a | object({
env = string
prefix = string
ghes_url = string
ghes_org = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_group_name = string
scale_errors = optional(list(string), [])
runner_specs = map(object({
ami_filter = object({
name = list(string)
state = list(string)
})
ami_kms_key_arn = string
ami_owners = list(string)
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(any, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
instance_types = list(string)
license_specifications = optional(list(object({
license_configuration_arn = string
})), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
use_dedicated_host = optional(bool, false)
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
enable_userdata = bool
instance_target_capacity_type = string
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
block_device_mappings = list(object({
delete_on_termination = bool
device_name = string
encrypted = bool
iops = number
kms_key_id = string
snapshot_id = string
throughput = number
volume_size = number
volume_type = string
}))
}))
}) | n/a | yes |
+| [runner\_configs](#input\_runner\_configs) | n/a | object({
env = string
prefix = string
ghes_url = string
ghes_org = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_group_name = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
})
})
}))
}) | n/a | yes |
| [tenant\_configs](#input\_tenant\_configs) | n/a | object({
ecr_registries = list(string)
tags = map(string)
}) | n/a | yes |
## Outputs
diff --git a/modules/platform/ec2_deployment/ami.tf b/modules/platform/ec2_deployment/ami.tf
index 967211818..e99f127a3 100644
--- a/modules/platform/ec2_deployment/ami.tf
+++ b/modules/platform/ec2_deployment/ami.tf
@@ -8,7 +8,7 @@ data "aws_ssm_parameter" "ami_id" {
}
data "aws_ami" "runner_ami" {
- for_each = var.runner_configs.runner_specs
+ for_each = local.ec2_runner_configs
most_recent = false
filter {
diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf
index 07ae6ce17..1fc915276 100644
--- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf
+++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf
@@ -4,7 +4,7 @@ data "aws_partition" "current" {}
locals {
runner_ami_ssm_parameter_names = {
- for key in keys(var.runner_configs.runner_specs) :
+ for key in keys(local.ec2_runner_configs) :
key => "/github-action-runners/${var.runner_configs.prefix}/${key}/runners/config/ami_id"
}
@@ -12,6 +12,16 @@ locals {
for key, name in local.runner_ami_ssm_parameter_names :
key => "arn:${data.aws_partition.current.partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${name}"
}
+
+ ec2_update_runner_ami_map = {
+ for key, runner_config in local.ec2_runner_configs :
+ key => {
+ resource_ssm_id = local.runner_ami_ssm_parameter_arns[key]
+ ssm_id = local.runner_ami_ssm_parameter_names[key]
+ ami_filter = local.ec2_compute_provider[key].ami.filter
+ ami_owners = local.ec2_compute_provider[key].ami.owners
+ }
+ }
}
module "ec2_update_runner_ssm_ami" {
@@ -26,14 +36,6 @@ module "ec2_update_runner_ssm_ami" {
log_level = var.runner_configs.log_level
tags = var.tenant_configs.tags
- runner_ami_map = {
- for key in keys(var.runner_configs.runner_specs) :
- key => {
- resource_ssm_id = local.runner_ami_ssm_parameter_arns[key]
- ssm_id = local.runner_ami_ssm_parameter_names[key]
- ami_filter = var.runner_configs.runner_specs[key].ami_filter
- ami_owners = var.runner_configs.runner_specs[key].ami_owners
- }
- }
+ runner_ami_map = local.ec2_update_runner_ami_map
}
diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/README.md b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/README.md
index 6f5b2908e..b67ae98ad 100644
--- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/README.md
+++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/README.md
@@ -56,7 +56,7 @@ Forge runner AMIs are updated as images are rebuilt and tested. This helper keep
| [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | `"INFO"` | no |
| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Retention in days for CloudWatch Log Group for the Lambdas. | `number` | `30` | no |
| [prefix](#input\_prefix) | Prefix for all resources | `string` | n/a | yes |
-| [runner\_ami\_map](#input\_runner\_ami\_map) | n/a | map(object({
resource_ssm_id = string
ssm_id = string
ami_filter = object({
name = list(string)
state = list(string)
})
ami_owners = list(string)
})) | n/a | yes |
+| [runner\_ami\_map](#input\_runner\_ami\_map) | n/a | map(object({
resource_ssm_id = string
ssm_id = string
ami_filter = map(list(string))
ami_owners = list(string)
})) | n/a | yes |
| [tags](#input\_tags) | Tags to apply to created resources. | `map(string)` | `{}` | no |
## Outputs
diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/tests/interface_contract.tftest.hcl
index 33655a486..1285d8266 100644
--- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/tests/interface_contract.tftest.hcl
+++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/tests/interface_contract.tftest.hcl
@@ -30,10 +30,8 @@ run "platform_ec2_deployment_ec2_update_runner_ssm_ami_interface_contract" {
"type = map(object({",
"resource_ssm_id = string",
"ssm_id = string",
- "ami_filter = object({",
- "name = list(string)",
- "state = list(string)",
- "ami_owners = list(string)",
+ "ami_filter = map(list(string))",
+ "ami_owners = list(string)",
"}))",
"variable \"tags\"",
"description = \"Tags to apply to created resources.\"",
@@ -71,7 +69,7 @@ run "platform_ec2_deployment_ec2_update_runner_ssm_ami_interface_contract" {
condition = (
output.expected_input_variable_count == 5
&& output.expected_output_value_count == 0
- && output.expected_interface_literal_count == 23
+ && output.expected_interface_literal_count == 21
)
error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals."
}
diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/variables.tf b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/variables.tf
index 1654e1de8..4277b8d52 100644
--- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/variables.tf
+++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami/variables.tf
@@ -25,10 +25,7 @@ variable "runner_ami_map" {
type = map(object({
resource_ssm_id = string
ssm_id = string
- ami_filter = object({
- name = list(string)
- state = list(string)
- })
- ami_owners = list(string)
+ ami_filter = map(list(string))
+ ami_owners = list(string)
}))
}
diff --git a/modules/platform/ec2_deployment/main.tf b/modules/platform/ec2_deployment/main.tf
index e57b295ea..677228a94 100644
--- a/modules/platform/ec2_deployment/main.tf
+++ b/modules/platform/ec2_deployment/main.tf
@@ -62,7 +62,7 @@ resource "aws_kms_alias" "github" {
}
data "aws_subnet" "runner_subnet" {
- for_each = toset(var.network_configs.subnet_ids)
+ for_each = local.active_ec2_subnet_ids
id = each.value
}
@@ -83,9 +83,7 @@ locals {
# Every runner OS delivers its (large) hook scripts via SSM; at job time the
# runner runs a small wrapper (hook_job_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.object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
scale_errors = optional(list(string), [])
runner_specs = map(object({
ami_filter = object({
name = list(string)
state = list(string)
})
ami_kms_key_arn = string
ami_owners = list(string)
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(any, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
instance_types = list(string)
license_specifications = optional(list(object({
license_configuration_arn = string
})), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
use_dedicated_host = optional(bool, false)
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
enable_userdata = bool
instance_target_capacity_type = string
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
block_device_mappings = list(object({
delete_on_termination = bool
device_name = string
encrypted = bool
iops = number
kms_key_id = string
snapshot_id = string
throughput = number
volume_size = number
volume_type = string
}))
}))
}) | n/a | yes |
+| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | 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({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
})
})
}))
}) | n/a | yes |
| [github\_webhook\_relay](#input\_github\_webhook\_relay) | Configuration for the (optional) webhook relay source module.object({
enabled = bool
destination_account_id = optional(string)
destination_event_bus_name = optional(string)
destination_region = optional(string)
destination_reader_role_arn = optional(string)
}) | {
"destination_account_id": "",
"destination_event_bus_name": "",
"destination_reader_role_arn": "",
"destination_region": "",
"enabled": false
} | no |
| [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | n/a | yes |
| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Logging retention period in days. | `string` | n/a | yes |
diff --git a/modules/platform/forge_runners/ec2_runners.tf b/modules/platform/forge_runners/ec2_runners.tf
index 009c0af64..ab3e04b05 100644
--- a/modules/platform/forge_runners/ec2_runners.tf
+++ b/modules/platform/forge_runners/ec2_runners.tf
@@ -43,7 +43,6 @@ module "ec2_runners" {
webhook_secret = aws_ssm_parameter.github_app_webhook_secret.value
}
runner_group_name = var.deployment_config.github.runner_group_name
- scale_errors = var.ec2_deployment_specs.scale_errors
runner_specs = var.ec2_deployment_specs.runner_specs
}
}
diff --git a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl
index b21bc69a2..8efc70a3a 100644
--- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl
+++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl
@@ -138,30 +138,85 @@ run "platform_forge_runners_interface_contract" {
"subnet_ids = list(string)",
"lambda_vpc_id = string",
"vpc_id = string",
- "scale_errors = optional(list(string), [])",
- "ami_filter = object({",
- "name = list(string)",
- "state = list(string)",
- "ami_kms_key_arn = string",
- "ami_owners = list(string)",
- "runner_labels = list(string)",
- "runner_os = string",
- "runner_architecture = string",
- "extra_labels = list(string)",
- "enable_dynamic_labels = optional(bool, false)",
- "aws_dynamic_labels_policy = optional(any, null)",
+ "runner_labels = list(string)",
+ "runner_os = string",
+ "runner_architecture = string",
+ "extra_labels = list(string)",
+ "enable_dynamic_labels = optional(bool, false)",
+ "aws_dynamic_labels_policy = optional(object({",
+ "blocked_keys = optional(list(string), [])",
+ "restricted_keys = optional(map(object({",
+ "allowed = optional(list(string), [])",
+ "denied = optional(list(string), [])",
+ "max = optional(string, null)",
"lambda_event_source_mapping_batch_size = optional(number, 10)",
"lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)",
"redrive_build_queue = optional(object({",
"enabled = optional(bool, true)",
"maxReceiveCount = optional(number, 10)",
- "}), {})",
- "max_instances = number",
- "min_run_time = number",
- "instance_types = list(string)",
- "license_specifications = optional(list(object({",
- "license_configuration_arn = string",
- "})), null)",
+ "max_instances = number",
+ "min_run_time = number",
+ "pool_config = list(object({",
+ "size = number",
+ "schedule_expression = string",
+ "schedule_expression_timezone = string",
+ "runner_user = string",
+ "compute_provider = object({",
+ "ec2 = object({",
+ "metadata_options = optional(object({",
+ "instance_metadata_tags = optional(string, \"enabled\")",
+ "http_endpoint = optional(string, \"enabled\")",
+ "http_tokens = optional(string, \"required\")",
+ "http_put_response_hop_limit = optional(number, 1)",
+ "ami = optional(object({",
+ "filter = optional(map(list(string)), { state = [\"available\"] })",
+ "owners = optional(list(string), [\"amazon\"])",
+ "id_ssm_parameter = optional(object({",
+ "kms_key = optional(object({",
+ "arn = string",
+ "block_device_mappings = optional(list(object({",
+ "delete_on_termination = optional(bool, true)",
+ "device_name = optional(string, \"/dev/xvda\")",
+ "encrypted = optional(bool, true)",
+ "iops = optional(number)",
+ "kms_key_id = optional(string)",
+ "snapshot_id = optional(string)",
+ "throughput = optional(number)",
+ "volume_initialization_rate = optional(number)",
+ "volume_size = number",
+ "volume_type = optional(string, \"gp3\")",
+ "create_service_linked_role_spot = optional(bool, false)",
+ "credit_specification = optional(string, null)",
+ "ebs_optimized = optional(bool, false)",
+ "cloudwatch_agent = optional(object({",
+ "config = optional(string, null)",
+ "binaries_syncer = optional(object({",
+ "detailed_monitoring_enabled = optional(bool, false)",
+ "ssm_enabled = optional(bool, false)",
+ "user_data = optional(object({",
+ "enabled = optional(bool, true)",
+ "template = optional(string, null)",
+ "content = optional(string, null)",
+ "pre_install = optional(string, \"\")",
+ "post_install = optional(string, \"\")",
+ "debug_logging_enabled = optional(bool, false)",
+ "instance_allocation_strategy = optional(string, \"lowest-price\")",
+ "instance_max_spot_price = optional(string, null)",
+ "instance_target_capacity_type = optional(string, \"spot\")",
+ "instance_type_priorities = optional(map(number), null)",
+ "instance_types = list(string)",
+ "additional_security_group_ids = optional(list(string), [])",
+ "instance_profile = optional(object({",
+ "name = string",
+ "enable_on_demand_failover_for_errors = optional(list(string), [])",
+ "scale_errors = optional(list(string), [",
+ "subnet_ids = optional(list(string), null)",
+ "vpc_id = optional(string, null)",
+ "cpu_options = optional(object({",
+ "core_count = optional(number)",
+ "threads_per_core = optional(number)",
+ "amd_sev_snp = optional(string)",
+ "nested_virtualization = optional(string)",
"placement = optional(object({",
"affinity = optional(string)",
"availability_zone = optional(string)",
@@ -172,66 +227,36 @@ run "platform_forge_runners_interface_contract" {
"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)",
- "pool_config = list(object({",
- "size = number",
- "schedule_expression = string",
- "schedule_expression_timezone = string",
- "runner_user = string",
- "enable_userdata = bool",
- "instance_target_capacity_type = string",
- "vpc_id = optional(string, null)",
- "subnet_ids = optional(list(string), null)",
- "block_device_mappings = list(object({",
- "delete_on_termination = bool",
- "device_name = string",
- "encrypted = bool",
- "iops = number",
- "kms_key_id = string",
- "snapshot_id = string",
- "throughput = number",
- "volume_size = number",
- "volume_type = string",
- "EC2 deployment configuration for GitHub Actions runners.",
+ "log_files = optional(list(object({",
+ "log_group_name = string",
+ "prefix_log_group = bool",
+ "file_path = string",
+ "log_stream_name = string",
+ "log_class = optional(string, \"STANDARD\")",
+ "tags = optional(map(string), {})",
+ "for runner_config in values(var.ec2_deployment_specs.runner_specs) :",
+ "length(runner_config.compute_provider.ec2.ami[*]) == 1",
+ "&& try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false)",
+ "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"",
+ "!runner_config.compute_provider.ec2.user_data.debug_logging_enabled",
+ "error_message = \"Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active.\"",
+ "length(runner_config.compute_provider.ec2.instance_profile[*]) == 0",
+ "error_message = \"Forge EC2 runner_specs do not support an external instance_profile.\"",
+ "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 : Subnets where the EC2 runners are launched.",
+ "- subnet_ids : Default subnets for EC2 runners.",
"- vpc_id : VPC that contains both runner and lambda subnets.",
- "- runner_specs : Map of runner pool keys to their EC2 sizing and",
- "scheduling configuration.",
- "- ami_filter : Name/state filters used to select the runner AMI.",
- "- ami_kms_key_arn : KMS key ARN used to encrypt AMI EBS volumes.",
- "- ami_owners : List of AWS account IDs that own the AMI.",
+ "- runner_specs : Map of EC2 runner lanes.",
"- runner_labels : Base GitHub labels applied to jobs for this pool.",
"- runner_os : Runner operating system (for example, linux).",
- "- runner_architecture: CPU architecture (for example, x86_64 or arm64).",
- "- extra_labels : Additional GitHub labels that further specialize",
- "this runner pool.",
- "- enable_dynamic_labels: Enables dynamic `ghr-` labels for this runner",
- "pool.",
- "- aws_dynamic_labels_policy: Optional policy for `ghr-ec2-*` labels for",
- "- lambda_event_source_mapping_batch_size: Optional maximum number of queued",
- "- lambda_event_source_mapping_maximum_batching_window_in_seconds: Optional",
"- redrive_build_queue: Optional dead-letter queue redrive configuration.",
- "- max_instances : Maximum number of EC2 runners in this pool.",
- "- min_run_time : Minimum job run time (in minutes) before a runner",
- "is eligible for scale-down.",
- "- instance_types : Allowed EC2 instance types for runners in this pool.",
- "- placement : Optional EC2 placement configuration for the runner",
- "launch template.",
- "- license_specifications: Optional EC2 License Manager configuration ARNs.",
- "- use_dedicated_host: Whether this runner pool should use EC2 dedicated",
- "hosts.",
- "- pool_config : List of pool size schedules (size + cron expression",
- "and optional time zone) controlling baseline capacity.",
- "- runner_user : OS user under which the GitHub runner process runs.",
- "- enable_userdata : Whether the module should inject its standard",
- "userdata to configure the runner VM.",
- "- instance_target_capacity_type: EC2 capacity type to use (spot or",
- "on-demand).",
- "- block_device_mappings: EBS volume configuration for the runner",
- "instances, including size, type, encryption, and KMS.",
+ "- compute_provider: Nested v2-compatible EC2 provider configuration.",
+ "compute_provider.ec2 fields:",
+ "- ami : Upstream-compatible EC2 AMI configuration.",
"variable \"github_webhook_relay\"",
"Configuration for the (optional) webhook relay source module.",
"If enabled=true we provision the API Gateway + source EventBridge forwarding rule.",
@@ -318,7 +343,7 @@ run "platform_forge_runners_interface_contract" {
condition = (
output.expected_input_variable_count == 10
&& output.expected_output_value_count == 5
- && output.expected_interface_literal_count == 259
+ && output.expected_interface_literal_count == 284
)
error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals."
}
diff --git a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl
index 1ff3bef86..e41c3d249 100644
--- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl
+++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl
@@ -17,6 +17,11 @@ run "platform_forge_runners_contract" {
"module \"github_webhook_relay\"",
"module \"redrive_deadletter\"",
"resource \"random_id\" \"random\"",
+ "runner_specs = var.ec2_deployment_specs.runner_specs",
+ "compute_provider = object({",
+ "ec2 = object({",
+ "scale_errors = optional(list(string), [",
+ "try(module.ec2_runners[0].ec2_runners_arn_map, {}),",
"resource \"aws_iam_policy\" \"role_assumption_for_forge_runners\"",
"resource \"aws_iam_policy\" \"ecr_access_for_ec2_instances\"",
"resource \"aws_servicecatalogappregistry_application\" \"forge\"",
diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf
index f286df895..acc8fcbc1 100644
--- a/modules/platform/forge_runners/variables.tf
+++ b/modules/platform/forge_runners/variables.tf
@@ -14,83 +14,181 @@ variable "ec2_deployment_specs" {
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
- scale_errors = optional(list(string), [])
runner_specs = map(object({
- ami_filter = object({
- name = list(string)
- state = list(string)
- })
- ami_kms_key_arn = string
- ami_owners = list(string)
- runner_labels = list(string)
- runner_os = string
- runner_architecture = string
- extra_labels = list(string)
- enable_dynamic_labels = optional(bool, false)
- aws_dynamic_labels_policy = optional(any, null)
+ runner_labels = list(string)
+ runner_os = string
+ runner_architecture = string
+ extra_labels = list(string)
+ enable_dynamic_labels = optional(bool, false)
+ aws_dynamic_labels_policy = optional(object({
+ blocked_keys = optional(list(string), [])
+ restricted_keys = optional(map(object({
+ allowed = optional(list(string), [])
+ denied = optional(list(string), [])
+ max = optional(string, null)
+ })), {})
+ }), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
- max_instances = number
- min_run_time = number
- instance_types = list(string)
- license_specifications = optional(list(object({
- license_configuration_arn = string
- })), null)
- placement = optional(object({
- affinity = optional(string)
- availability_zone = optional(string)
- group_id = optional(string)
- group_name = optional(string)
- host_id = optional(string)
- host_resource_group_arn = optional(string)
- spread_domain = optional(string)
- tenancy = optional(string)
- partition_number = optional(number)
- }), null)
- use_dedicated_host = optional(bool, false)
+ max_instances = number
+ min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
- runner_user = string
- enable_userdata = bool
- instance_target_capacity_type = string
- vpc_id = optional(string, null)
- subnet_ids = optional(list(string), null)
- block_device_mappings = list(object({
- delete_on_termination = bool
- device_name = string
- encrypted = bool
- iops = number
- kms_key_id = string
- snapshot_id = string
- throughput = number
- volume_size = number
- volume_type = string
- }))
+ runner_user = string
+ compute_provider = object({
+ ec2 = 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), {})
+ })
+ })
}))
})
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.ec2_deployment_specs.runner_specs) :
+ length(runner_config.compute_provider.ec2.ami[*]) == 1
+ && try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false)
+ ])
+ error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported."
+ }
+
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.ec2_deployment_specs.runner_specs) :
+ !runner_config.compute_provider.ec2.user_data.debug_logging_enabled
+ ])
+ error_message = "Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active."
+ }
+
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.ec2_deployment_specs.runner_specs) :
+ length(runner_config.compute_provider.ec2.instance_profile[*]) == 0
+ ])
+ error_message = "Forge EC2 runner_specs do not support an external instance_profile."
+ }
+
description = <<-EOT
- EC2 deployment configuration for GitHub Actions runners.
+ EC2 deployment configuration for GitHub Actions runners. The public runner
+ shape follows the nested v2 EC2 contract and is translated internally to the
+ released upstream v1 multi_runner_config interface.
Top-level fields:
- lambda_subnet_ids: Subnets where runner-related lambdas execute.
These can be more permissive than the runner subnets.
- - subnet_ids : Subnets where the EC2 runners are launched.
+ - subnet_ids : Default subnets for EC2 runners.
- vpc_id : VPC that contains both runner and lambda subnets.
- - runner_specs : Map of runner pool keys to their EC2 sizing and
- scheduling configuration.
+ - runner_specs : Map of EC2 runner lanes.
runner_specs[*] object fields:
- - ami_filter : Name/state filters used to select the runner AMI.
- - ami_kms_key_arn : KMS key ARN used to encrypt AMI EBS volumes.
- - ami_owners : List of AWS account IDs that own the AMI.
- runner_labels : Base GitHub labels applied to jobs for this pool.
- runner_os : Runner operating system (for example, linux).
- runner_architecture: CPU architecture (for example, x86_64 or arm64).
@@ -109,24 +207,28 @@ variable "ec2_deployment_specs" {
Controls whether redrive is enabled and how many times a
message can be received before moving to the dead-letter
queue.
- - max_instances : Maximum number of EC2 runners in this pool.
+ - max_instances : Maximum number of runners in this pool.
- min_run_time : Minimum job run time (in minutes) before a runner
is eligible for scale-down.
- - instance_types : Allowed EC2 instance types for runners in this pool.
- - placement : Optional EC2 placement configuration for the runner
- launch template.
- - license_specifications: Optional EC2 License Manager configuration ARNs.
- - use_dedicated_host: Whether this runner pool should use EC2 dedicated
- hosts.
- pool_config : List of pool size schedules (size + cron expression
and optional time zone) controlling baseline capacity.
- runner_user : OS user under which the GitHub runner process runs.
- - enable_userdata : Whether the module should inject its standard
- userdata to configure the runner VM.
- - instance_target_capacity_type: EC2 capacity type to use (spot or
- on-demand).
- - block_device_mappings: EBS volume configuration for the runner
- instances, including size, type, encryption, and KMS.
+ - compute_provider: Nested v2-compatible EC2 provider configuration.
+
+ compute_provider.ec2 fields:
+ - ami : Upstream-compatible EC2 AMI configuration.
+ Forge requires a module-managed AMI block; null and
+ external AMI parameter ownership are unsupported.
+ - metadata_options: EC2 instance metadata service configuration.
+ - block_device_mappings: EBS mappings for runner instances.
+ - cloudwatch_agent/binaries_syncer/user_data: Runner bootstrap configuration.
+ user_data.debug_logging_enabled must remain false while
+ the stable upstream v1 adapter is active.
+ - instance_types and allocation fields: EC2 Fleet capacity configuration.
+ - vpc_id/subnet_ids/additional_security_group_ids: Per-lane networking.
+ - cpu_options/placement/license_specifications: EC2 launch-template options.
+ - instance_profile: Upstream contract field reserved for future Forge support.
+ - log_files/tags : EC2 logging and resource tags.
EOT
}