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__.tftpl) that fetches the # param, decompresses it, and execs the real hook (hooks/job__). - hook_ssm_oses = toset([ - for key, val in var.runner_configs.runner_specs : val["runner_os"] - ]) + hook_ssm_oses = toset(values(local.active_ec2_runner_oses)) } resource "aws_ssm_parameter" "hook_job_started" { @@ -165,143 +163,9 @@ module "runners" { runner_binaries_syncer_lambda_zip = "${data.external.download_lambdas.result.path}/runner-binaries-syncer.zip" runners_lambda_zip = "${data.external.download_lambdas.result.path}/runners.zip" - # Configure the various types of runners we provide, along with on-demand - # versus standby pools, etc. - multi_runner_config = { - for key, val in var.runner_configs.runner_specs : - key => { - matcherConfig : { - # Generate all unique combinations of extra_labels and combine them with runner_labels - labelMatchers = concat( - [val["runner_labels"]], - concat([ - # Iterate over lengths from 1 to the length of extra_labels - for length in range(1, length(val["extra_labels"]) + 1) : concat([ - # For each length, iterate over starting positions to slice the extra_labels - for start in range(0, length(val["extra_labels"]) - length + 1) : - # Combine runner_labels and the current slice of extra_labels - concat(val["runner_labels"], slice(val["extra_labels"], start, start + length)) - ]) - ]...) - ) - exactMatch = true - enableDynamicLabels = val["enable_dynamic_labels"] - awsDynamicLabelsPolicy = val["aws_dynamic_labels_policy"] - } - redrive_build_queue = val["redrive_build_queue"] - runner_config = { - runner_metadata_options = { - "http_endpoint" : "enabled", - "http_put_response_hop_limit" : 2, - "http_tokens" : "optional", - "instance_metadata_tags" : "enabled" - } - delay_webhook_event = 0 - runner_ec2_tags = var.tenant_configs.tags - runner_os = val["runner_os"] - runner_architecture = val["runner_architecture"] - runner_extra_labels = val["extra_labels"] - enable_ssm_on_runners = true - instance_types = val["instance_types"] - runners_maximum_count = val["max_instances"] - scale_down_schedule_expression = "cron(*/5 * * * ? *)" - minimum_running_time_in_minutes = val["min_run_time"] - runner_group_name = var.runner_configs.runner_group_name - enable_runner_binaries_syncer = false - enable_userdata = val["enable_userdata"] - scale_errors = var.runner_configs.scale_errors - lambda_event_source_mapping_batch_size = val["lambda_event_source_mapping_batch_size"] - lambda_event_source_mapping_maximum_batching_window_in_seconds = val["lambda_event_source_mapping_maximum_batching_window_in_seconds"] - userdata_template = "${local.user_data_prefix}/user_data_${val["runner_os"]}.tftpl" - userdata_pre_install = "# No pre-install steps." - userdata_post_install = templatefile( - local.userdata_template_post_install, - { - runner_user = val["runner_user"] - ecr_registries = var.tenant_configs.ecr_registries - } - ) - # The real hook scripts live (gzip+base64) in SSM; the runner hook is a - # small wrapper that fetches+decompresses+runs them at job time. The - # wrapper is small enough to inline in user_data. See hooks/ for the real - # scripts and hook_job_*_.tftpl for the wrappers. - runner_hook_job_started = templatefile( - "${local.user_data_prefix}/hook_job_started_${val["runner_os"]}.tftpl", - { - param_name = aws_ssm_parameter.hook_job_started[val["runner_os"]].name - region = var.aws_region - } - ) - runner_hook_job_completed = templatefile( - "${local.user_data_prefix}/hook_job_completed_${val["runner_os"]}.tftpl", - { - param_name = aws_ssm_parameter.hook_job_completed[val["runner_os"]].name - region = var.aws_region - } - ) - enable_runner_detailed_monitoring = true - runner_run_as = val["runner_user"] - block_device_mappings = val["block_device_mappings"] - license_specifications = val["license_specifications"] - placement = val["placement"] - use_dedicated_host = val["use_dedicated_host"] - runner_log_files = concat( - // Linux/macOS-only logs - val["runner_os"] == "windows" ? [] : [ - { - "log_group_name" : "forge-logs", - "prefix_log_group" : true, - "file_path" : "/var/log/syslog", - "log_stream_name" : "{instance_id}/syslog" - }, - ], - // Logs that exist on all OSes, with OS-specific paths - [ - { - "log_group_name" : "forge-logs", - "prefix_log_group" : true, - "file_path" : val["runner_os"] == "windows" ? "C:/UserData.log" : "/var/log/user-data.log", - "log_stream_name" : "{instance_id}/user-data" - }, - { - "log_group_name" : "forge-logs", - "prefix_log_group" : true, - "file_path" : val["runner_os"] == "windows" ? "C:/actions-runner/_diag/Runner_*.log" : "/opt/actions-runner/_diag/Runner_**.log", - "log_stream_name" : "{instance_id}/runner" - }, - { - "log_group_name" : "forge-logs", - "prefix_log_group" : true, - "file_path" : val["runner_os"] == "windows" ? "C:/Users/Administrator/AppData/Local/Temp/hook_*.log" : "/home/${val["runner_user"]}/hook.log", - "log_stream_name" : "{instance_id}/hook" - }, - ], - ) - ami = { - owners = val["ami_owners"] - filter = val["ami_filter"] - kms_key_arn = val["ami_kms_key_arn"] - } - instance_target_capacity_type = val["instance_target_capacity_type"] - enable_job_queued_check = false - runner_iam_role_managed_policy_arns = concat( - var.runner_configs.runner_iam_role_managed_policy_arns, - [ - aws_iam_policy.ec2_tags.arn, - aws_iam_policy.runner_hooks_ssm_read.arn, - ], - ) - vpc_id = val["vpc_id"] - subnet_ids = val["subnet_ids"] - enable_ephemeral_runners = true - create_service_linked_role_spot = true - enable_organization_runners = true - job_queue_retention_in_seconds = 172800 - pool_config = val["pool_config"] - pool_runner_owner = var.runner_configs.ghes_org - } - } - } + # Temporary compatibility boundary: Forge accepts the nested v2 EC2 input + # shape, then translates it to the released upstream v1 contract. + multi_runner_config = local.multi_runner_config_v1 depends_on = [ data.external.download_lambdas, diff --git a/modules/platform/ec2_deployment/outputs.tf b/modules/platform/ec2_deployment/outputs.tf index f42d52f32..26e11df8b 100644 --- a/modules/platform/ec2_deployment/outputs.tf +++ b/modules/platform/ec2_deployment/outputs.tf @@ -12,15 +12,14 @@ output "ec2_runners_arn_map" { output "ec2_runners_ami_name_map" { value = { - for runner_key, runner in module.runners.runners_map : runner_key => data.aws_ami.runner_ami[runner_key].name + for runner_key in keys(local.ec2_runner_configs) : runner_key => data.aws_ami.runner_ami[runner_key].name } description = "Map of EC2 runner keys to the AMI names used for each runner." } output "ec2_runners_labels_map" { value = { - for runner_key, spec in var.runner_configs.runner_specs : - runner_key => concat(spec.runner_labels, spec.extra_labels) + for runner_key in keys(local.ec2_runner_configs) : runner_key => local.runner_labels[runner_key] } description = "Map of EC2 runner keys to their base and extra GitHub labels." } diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf new file mode 100644 index 000000000..9e0da1511 --- /dev/null +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -0,0 +1,209 @@ +locals { + ec2_runner_configs = var.runner_configs.runner_specs + + active_ec2_runner_oses = { + for key, runner_config in local.ec2_runner_configs : + key => runner_config.runner_os + } + + active_ec2_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 + ])) + + # This is the upstream EC2 provider's default AMI selection. Normalize it + # here so the provider and Forge's scheduled AMI refresh use one effective + # filter map. + ec2_default_ami_filters = { + for key, runner_config in local.ec2_runner_configs : + key => ({ + windows = { name = ["Windows_Server-2022-English-Full-ECS_Optimized-*"] } + linux = runner_config.runner_architecture == "arm64" ? { name = ["al2023-ami-2023.*-kernel-6.*-arm64"] } : { name = ["al2023-ami-2023.*-kernel-6.*-x86_64"] } + osx = runner_config.runner_architecture == "arm64" ? { name = ["amzn-ec2-macos-15.*-arm64"] } : { name = ["amzn-ec2-macos-15.*"] } + })[runner_config.runner_os] + } + + runner_labels = { + for key, runner_config in var.runner_configs.runner_specs : + key => concat(runner_config.runner_labels, runner_config.extra_labels) + } + + forge_ec2_log_files = { + for key, runner_config in local.ec2_runner_configs : + key => concat( + runner_config.runner_os == "windows" ? [] : [ + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = "/var/log/syslog" + log_stream_name = "{instance_id}/syslog" + }, + ], + [ + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = runner_config.runner_os == "windows" ? "C:/UserData.log" : "/var/log/user-data.log" + log_stream_name = "{instance_id}/user-data" + }, + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = runner_config.runner_os == "windows" ? "C:/actions-runner/_diag/Runner_*.log" : "/opt/actions-runner/_diag/Runner_**.log" + log_stream_name = "{instance_id}/runner" + }, + { + log_group_name = "forge-logs" + prefix_log_group = true + file_path = runner_config.runner_os == "windows" ? "C:/Users/Administrator/AppData/Local/Temp/hook_*.log" : "/home/${runner_config.runner_user}/hook.log" + log_stream_name = "{instance_id}/hook" + }, + ], + ) + } + + ec2_compute_provider = { + for key, runner_config in local.ec2_runner_configs : + key => merge( + runner_config.compute_provider.ec2, + { + ami = runner_config.compute_provider.ec2.ami == null ? null : merge( + runner_config.compute_provider.ec2.ami, + { + filter = merge( + local.ec2_default_ami_filters[key], + runner_config.compute_provider.ec2.ami.filter, + ) + } + ) + user_data = merge( + runner_config.compute_provider.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 + post_install = join("\n", compact([ + runner_config.compute_provider.ec2.user_data.post_install, + templatefile( + local.userdata_template_post_install, + { + runner_user = runner_config.runner_user + ecr_registries = var.tenant_configs.ecr_registries + } + ), + ])) + } + ) + log_files = coalesce(runner_config.compute_provider.ec2.log_files, local.forge_ec2_log_files[key]) + tags = merge(var.tenant_configs.tags, runner_config.compute_provider.ec2.tags) + } + ) + } + + # Keep Forge's public input aligned with the nested v2 EC2 contract while + # the upstream module remains on its stable v1 multi_runner_config path. + multi_runner_config_v1 = { + for key, runner_config in local.ec2_runner_configs : + key => { + runner_config = { + runner_os = runner_config.runner_os + runner_architecture = runner_config.runner_architecture + runner_metadata_options = local.ec2_compute_provider[key].metadata_options + runner_extra_labels = runner_config.extra_labels + runner_group_name = var.runner_configs.runner_group_name + runner_run_as = runner_config.runner_user + runners_maximum_count = runner_config.max_instances + enable_ephemeral_runners = true + enable_organization_runners = true + + ami = { + filter = local.ec2_compute_provider[key].ami.filter + owners = local.ec2_compute_provider[key].ami.owners + id_ssm_parameter_arn = try(local.ec2_compute_provider[key].ami.id_ssm_parameter.arn, null) + kms_key_arn = try(local.ec2_compute_provider[key].ami.kms_key.arn, null) + } + + block_device_mappings = local.ec2_compute_provider[key].block_device_mappings + create_service_linked_role_spot = local.ec2_compute_provider[key].create_service_linked_role_spot + credit_specification = local.ec2_compute_provider[key].credit_specification + ebs_optimized = local.ec2_compute_provider[key].ebs_optimized + enable_cloudwatch_agent = local.ec2_compute_provider[key].cloudwatch_agent.enabled + cloudwatch_config = local.ec2_compute_provider[key].cloudwatch_agent.config + enable_runner_binaries_syncer = local.ec2_compute_provider[key].binaries_syncer.enabled + enable_runner_detailed_monitoring = local.ec2_compute_provider[key].detailed_monitoring_enabled + enable_ssm_on_runners = local.ec2_compute_provider[key].ssm_enabled + enable_userdata = local.ec2_compute_provider[key].user_data.enabled + userdata_template = local.ec2_compute_provider[key].user_data.template + userdata_content = local.ec2_compute_provider[key].user_data.content + userdata_pre_install = local.ec2_compute_provider[key].user_data.pre_install + userdata_post_install = local.ec2_compute_provider[key].user_data.post_install + instance_allocation_strategy = local.ec2_compute_provider[key].instance_allocation_strategy + instance_max_spot_price = local.ec2_compute_provider[key].instance_max_spot_price + instance_target_capacity_type = local.ec2_compute_provider[key].instance_target_capacity_type + instance_type_priorities = local.ec2_compute_provider[key].instance_type_priorities + instance_types = local.ec2_compute_provider[key].instance_types + runner_additional_security_group_ids = local.ec2_compute_provider[key].additional_security_group_ids + enable_on_demand_failover_for_errors = local.ec2_compute_provider[key].enable_on_demand_failover_for_errors + scale_errors = local.ec2_compute_provider[key].scale_errors + subnet_ids = local.ec2_compute_provider[key].subnet_ids + vpc_id = local.ec2_compute_provider[key].vpc_id + cpu_options = local.ec2_compute_provider[key].cpu_options + placement = local.ec2_compute_provider[key].placement + license_specifications = local.ec2_compute_provider[key].license_specifications + use_dedicated_host = local.ec2_compute_provider[key].use_dedicated_host + runner_log_files = local.ec2_compute_provider[key].log_files + runner_ec2_tags = local.ec2_compute_provider[key].tags + + delay_webhook_event = 0 + job_queue_retention_in_seconds = 172800 + lambda_event_source_mapping_batch_size = runner_config.lambda_event_source_mapping_batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds + enable_job_queued_check = false + scale_down_schedule_expression = "cron(*/5 * * * ? *)" + minimum_running_time_in_minutes = runner_config.min_run_time + pool_config = runner_config.pool_config + pool_runner_owner = var.runner_configs.ghes_org + + runner_hook_job_started = templatefile( + "${local.user_data_prefix}/hook_job_started_${runner_config.runner_os}.tftpl", + { + param_name = aws_ssm_parameter.hook_job_started[runner_config.runner_os].name + region = var.aws_region + } + ) + runner_hook_job_completed = templatefile( + "${local.user_data_prefix}/hook_job_completed_${runner_config.runner_os}.tftpl", + { + param_name = aws_ssm_parameter.hook_job_completed[runner_config.runner_os].name + region = var.aws_region + } + ) + runner_iam_role_managed_policy_arns = concat( + var.runner_configs.runner_iam_role_managed_policy_arns, + [ + aws_iam_policy.ec2_tags.arn, + aws_iam_policy.runner_hooks_ssm_read.arn, + ], + ) + } + + matcherConfig = { + labelMatchers = length(runner_config.extra_labels) == 0 ? [runner_config.runner_labels] : concat( + [runner_config.runner_labels], + concat([ + for label_count in range(1, length(runner_config.extra_labels) + 1) : concat([ + for start in range(0, length(runner_config.extra_labels) - label_count + 1) : + concat(runner_config.runner_labels, slice(runner_config.extra_labels, start, start + label_count)) + ]) + ]...) + ) + exactMatch = true + enableDynamicLabels = runner_config.enable_dynamic_labels + awsDynamicLabelsPolicy = runner_config.aws_dynamic_labels_policy + } + + redrive_build_queue = runner_config.redrive_build_queue + } + } +} diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl new file mode 100644 index 000000000..f3eaa7306 --- /dev/null +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -0,0 +1,275 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + arn = "arn:aws:iam::123456789012:user/test" + user_id = "AIDATEST" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + dns_suffix = "amazonaws.com" + } + } + + mock_data "aws_subnet" { + defaults = { + arn = "arn:aws:ec2:eu-west-1:123456789012:subnet/subnet-test" + availability_zone = "eu-west-1a" + cidr_block = "10.0.0.0/24" + vpc_id = "vpc-test" + } + } + + mock_data "aws_ssm_parameter" { + defaults = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/test" + name = "/test" + type = "String" + value = "ami-0123456789abcdef0" + } + } + + mock_data "aws_ami" { + defaults = { + architecture = "x86_64" + id = "ami-0123456789abcdef0" + image_type = "machine" + name = "forge-test-ami" + root_device_name = "/dev/xvda" + root_device_type = "ebs" + virtualization_type = "hvm" + } + } + + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/mock-runner" + } + } + + mock_resource "aws_iam_policy" { + defaults = { + arn = "arn:aws:iam::123456789012:policy/mock" + } + } + + mock_resource "aws_kms_key" { + defaults = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/00000000-0000-0000-0000-000000000000" + key_id = "00000000-0000-0000-0000-000000000000" + } + } + + mock_resource "aws_sqs_queue" { + defaults = { + arn = "arn:aws:sqs:eu-west-1:123456789012:mock" + id = "https://sqs.eu-west-1.amazonaws.com/123456789012/mock" + url = "https://sqs.eu-west-1.amazonaws.com/123456789012/mock" + } + } +} + +mock_provider "external" { + mock_data "external" { + defaults = { + result = { + path = "/private/tmp/forge-test-lambda-cache" + repo = "github-aws-runners/terraform-aws-github-runner" + version = "local-cache" + } + } + } +} + +mock_provider "archive" {} +mock_provider "local" {} +mock_provider "null" {} +mock_provider "random" {} + +variables { + aws_region = "eu-west-1" + + network_configs = { + vpc_id = "vpc-test" + subnet_ids = ["subnet-default"] + lambda_vpc_id = "vpc-test" + lambda_subnet_ids = ["subnet-test"] + } + + tenant_configs = { + ecr_registries = [] + tags = { + Environment = "test" + } + } + + runner_configs = { + env = "test" + prefix = "forge-test" + ghes_url = "" + ghes_org = "cisco-open" + log_level = "info" + logging_retention_in_days = "3" + github_app = { + key_base64 = "dGVzdA==" + id = "12345" + webhook_secret = "test" + } + runner_iam_role_managed_policy_arns = [] + runner_group_name = "Default" + runner_specs = { + ec2 = { + runner_labels = ["self-hosted", "ec2"] + runner_os = "linux" + runner_architecture = "x64" + extra_labels = [] + max_instances = 2 + min_run_time = 5 + pool_config = [] + runner_user = "ec2-user" + compute_provider = { + ec2 = { + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" + } + ami = { + filter = { + name = ["forge-*"] + state = ["available"] + } + owners = ["123456789012"] + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + } + } + cloudwatch_agent = { + enabled = true + config = "{\"agent\":{}}" + } + binaries_syncer = { + enabled = false + } + detailed_monitoring_enabled = true + ebs_optimized = true + instance_allocation_strategy = "prioritized" + instance_type_priorities = { "m7i.large" = 1 } + instance_types = ["m7i.large"] + instance_target_capacity_type = "on-demand" + additional_security_group_ids = ["sg-runner"] + scale_errors = ["InsufficientInstanceCapacity"] + ssm_enabled = true + subnet_ids = ["subnet-override"] + tags = { Lane = "ec2" } + user_data = { + enabled = true + pre_install = "caller-pre" + post_install = "caller-post" + } + 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" + }] + } + } + } + + } + } +} + +run "ec2_v2_input_v1_adapter_plan" { + command = plan + + plan_options { + target = [ + data.aws_subnet.runner_subnet, + module.runners.aws_sqs_queue.queued_builds, + ] + } + + assert { + condition = toset(keys(local.ec2_runner_configs)) == toset(["ec2"]) + error_message = "EC2 provider filtering must retain every EC2 lane." + } + + assert { + condition = toset(keys(local.multi_runner_config_v1)) == toset(["ec2"]) + error_message = "The stable v1 adapter must preserve every EC2 lane key." + } + + assert { + condition = local.active_ec2_subnet_ids == toset(["subnet-override"]) + error_message = "EC2 effective subnet resolution must preserve per-lane 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 + ) + error_message = "The v1 adapter must flatten the nested EC2 AMI and fleet configuration." + } + + assert { + condition = ( + local.multi_runner_config_v1.ec2.runner_config.runner_metadata_options.http_tokens == "optional" + && local.multi_runner_config_v1.ec2.runner_config.runner_metadata_options.http_put_response_hop_limit == 2 + && local.multi_runner_config_v1.ec2.runner_config.enable_cloudwatch_agent + && local.multi_runner_config_v1.ec2.runner_config.cloudwatch_config == "{\"agent\":{}}" + && !local.multi_runner_config_v1.ec2.runner_config.enable_runner_binaries_syncer + && local.multi_runner_config_v1.ec2.runner_config.enable_runner_detailed_monitoring + && local.multi_runner_config_v1.ec2.runner_config.enable_ssm_on_runners + && tolist(local.multi_runner_config_v1.ec2.runner_config.runner_additional_security_group_ids) == tolist(["sg-runner"]) + ) + error_message = "The v1 adapter must preserve nested EC2 bootstrap, metadata, and networking settings." + } + + assert { + condition = ( + local.multi_runner_config_v1.ec2.runner_config.userdata_pre_install == "caller-pre" + && startswith(local.multi_runner_config_v1.ec2.runner_config.userdata_post_install, "caller-post\n") + && length(local.multi_runner_config_v1.ec2.runner_config.runner_log_files) == 4 + && local.multi_runner_config_v1.ec2.runner_config.runner_ec2_tags.Environment == "test" + && local.multi_runner_config_v1.ec2.runner_config.runner_ec2_tags.Lane == "ec2" + ) + error_message = "The v1 adapter must retain Forge user-data, logging, and tag overlays." + } + + assert { + condition = ( + tolist(local.ec2_default_ami_filters.ec2.name) == tolist(["al2023-ami-2023.*-kernel-6.*-x86_64"]) + && tolist(local.ec2_compute_provider.ec2.ami.filter.name) == tolist(["forge-*"]) + && tolist(local.ec2_compute_provider.ec2.ami.filter.state) == tolist(["available"]) + ) + error_message = "The scheduled AMI refresh must merge upstream defaults with caller filters." + } + + assert { + condition = ( + length(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers) == 1 + && tolist(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) + ) + error_message = "Empty extra labels must retain the base label matcher." + } +} diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index 481a95e60..0c9db034e 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -44,31 +44,86 @@ run "platform_ec2_deployment_interface_contract" { "webhook_secret = string", "runner_iam_role_managed_policy_arns = list(string)", "runner_group_name = string", - "scale_errors = optional(list(string), [])", "runner_specs = map(object({", - "ami_filter = object({", - "name = list(string)", - "state = list(string)", - "ami_kms_key_arn = string", - "ami_owners = list(string)", - "runner_labels = list(string)", - "runner_os = string", - "runner_architecture = string", - "extra_labels = list(string)", - "enable_dynamic_labels = optional(bool, false)", - "aws_dynamic_labels_policy = optional(any, null)", + "runner_labels = list(string)", + "runner_os = string", + "runner_architecture = string", + "extra_labels = list(string)", + "enable_dynamic_labels = optional(bool, false)", + "aws_dynamic_labels_policy = optional(object({", + "blocked_keys = optional(list(string), [])", + "restricted_keys = optional(map(object({", + "allowed = optional(list(string), [])", + "denied = optional(list(string), [])", + "max = optional(string, null)", "lambda_event_source_mapping_batch_size = optional(number, 10)", "lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)", "redrive_build_queue = optional(object({", "enabled = optional(bool, true)", "maxReceiveCount = optional(number, 10)", - "}), {})", - "max_instances = number", - "min_run_time = number", - "instance_types = list(string)", - "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)", @@ -79,54 +134,67 @@ run "platform_ec2_deployment_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", + "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.runner_configs.runner_specs) :", "variable \"tenant_configs\"", "ecr_registries = list(string)", "tags = map(string)", - "lambda_event_source_mapping_batch_size = val[\"lambda_event_source_mapping_batch_size\"]", - "lambda_event_source_mapping_maximum_batching_window_in_seconds = val[\"lambda_event_source_mapping_maximum_batching_window_in_seconds\"]", - "awsDynamicLabelsPolicy = val[\"aws_dynamic_labels_policy\"]", - "redrive_build_queue = val[\"redrive_build_queue\"]", - "output \"ec2_runners_ami_name_map\"", - "value = {", - "for runner_key, runner in module.runners.runners_map : runner_key => data.aws_ami.runner_ami[runner_key].name", - "description = \"Map of EC2 runner keys to the AMI names used for each runner.\"", + "ec2_runner_configs = var.runner_configs.runner_specs", + "length(runner_config.compute_provider.ec2.ami[*]) == 1", + "&& try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false)", + "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"", + "!runner_config.compute_provider.ec2.user_data.debug_logging_enabled", + "error_message = \"Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active.\"", + "length(runner_config.compute_provider.ec2.instance_profile[*]) == 0", + "error_message = \"Forge EC2 runner_specs do not support an external instance_profile.\"", + "active_ec2_subnet_ids = toset(flatten([", + "ec2_default_ami_filters = {", + "windows = { name = [\"Windows_Server-2022-English-Full-ECS_Optimized-*\"] }", + "linux = runner_config.runner_architecture == \"arm64\" ? { name = [\"al2023-ami-2023.*-kernel-6.*-arm64\"] } : { name = [\"al2023-ami-2023.*-kernel-6.*-x86_64\"] }", + "osx = runner_config.runner_architecture == \"arm64\" ? { name = [\"amzn-ec2-macos-15.*-arm64\"] } : { name = [\"amzn-ec2-macos-15.*\"] }", + "ec2_update_runner_ami_map = {", + "local.ec2_default_ami_filters[key],", + "ami = runner_config.compute_provider.ec2.ami == null ? null : merge(", + "runner_config.compute_provider.ec2.ami.filter,", + "ami_filter = local.ec2_compute_provider[key].ami.filter", + "ami_owners = local.ec2_compute_provider[key].ami.owners", + "runner_ami_map = local.ec2_update_runner_ami_map", + "key => merge(", + "runner_config.compute_provider.ec2,", + "log_files = coalesce(runner_config.compute_provider.ec2.log_files, local.forge_ec2_log_files[key])", + "tags = merge(var.tenant_configs.tags, runner_config.compute_provider.ec2.tags)", + "multi_runner_config_v1 = {", + "runner_metadata_options = local.ec2_compute_provider[key].metadata_options", + "id_ssm_parameter_arn = try(local.ec2_compute_provider[key].ami.id_ssm_parameter.arn, null)", + "kms_key_arn = try(local.ec2_compute_provider[key].ami.kms_key.arn, null)", + "enable_cloudwatch_agent = local.ec2_compute_provider[key].cloudwatch_agent.enabled", + "enable_userdata = local.ec2_compute_provider[key].user_data.enabled", + "runner_additional_security_group_ids = local.ec2_compute_provider[key].additional_security_group_ids", + "runner_log_files = local.ec2_compute_provider[key].log_files", + "runner_ec2_tags = local.ec2_compute_provider[key].tags", + "runner_iam_role_managed_policy_arns = concat(", + "labelMatchers = length(runner_config.extra_labels) == 0 ? [runner_config.runner_labels] : concat(", + "multi_runner_config = local.multi_runner_config_v1", "output \"ec2_runners_arn_map\"", "for runner_key, runner in module.runners.runners_map : runner_key => runner.role_runner[0].arn", - "description = \"Map of EC2 runner keys to their IAM role ARNs.\"", + "output \"ec2_runners_ami_name_map\"", + "data.aws_ami.runner_ami[runner_key].name", "output \"ec2_runners_labels_map\"", - "runner_key => concat(spec.runner_labels, spec.extra_labels)", - "description = \"Map of EC2 runner keys to their base and extra GitHub labels.\"", "output \"event_bus_name\"", "value = module.runners.webhook.eventbridge.event_bus.name", - "description = \"Name of the EventBridge event bus used by the webhook relay.\"", "output \"subnet_cidr_blocks\"", "value = { for id, subnet in data.aws_subnet.runner_subnet : id => subnet.cidr_block }", - "description = \"Map of EC2 runner subnet IDs to their CIDR blocks.\"", "output \"webhook_endpoint\"", "value = module.runners.webhook.endpoint", - "description = \"Public HTTPS endpoint URL for the GitHub Actions webhook relay.\"", ] } @@ -159,7 +227,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 == 105 + && output.expected_interface_literal_count == 173 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index c8a567e34..60e868dfd 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -18,67 +18,167 @@ variable "runner_configs" { }) runner_iam_role_managed_policy_arns = list(string) runner_group_name = string - scale_errors = optional(list(string), []) runner_specs = map(object({ - ami_filter = object({ - name = list(string) - state = list(string) - }) - ami_kms_key_arn = string - ami_owners = list(string) - runner_labels = list(string) - runner_os = string - runner_architecture = string - extra_labels = list(string) - enable_dynamic_labels = optional(bool, false) - aws_dynamic_labels_policy = optional(any, null) + runner_labels = list(string) + runner_os = string + runner_architecture = string + extra_labels = list(string) + enable_dynamic_labels = optional(bool, false) + aws_dynamic_labels_policy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) lambda_event_source_mapping_batch_size = optional(number, 10) lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0) redrive_build_queue = optional(object({ enabled = optional(bool, true) maxReceiveCount = optional(number, 10) }), {}) - max_instances = number - min_run_time = number - instance_types = list(string) - license_specifications = optional(list(object({ - license_configuration_arn = string - })), null) - placement = optional(object({ - affinity = optional(string) - availability_zone = optional(string) - group_id = optional(string) - group_name = optional(string) - host_id = optional(string) - host_resource_group_arn = optional(string) - spread_domain = optional(string) - tenancy = optional(string) - partition_number = optional(number) - }), null) - use_dedicated_host = optional(bool, false) + max_instances = number + min_run_time = number pool_config = list(object({ size = number schedule_expression = string schedule_expression_timezone = string })) - runner_user = string - enable_userdata = bool - instance_target_capacity_type = string - vpc_id = optional(string, null) - subnet_ids = optional(list(string), null) - block_device_mappings = list(object({ - delete_on_termination = bool - device_name = string - encrypted = bool - iops = number - kms_key_id = string - snapshot_id = string - throughput = number - volume_size = number - volume_type = string - })) + runner_user = string + compute_provider = object({ + ec2 = 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.runner_configs.runner_specs) : + length(runner_config.compute_provider.ec2.ami[*]) == 1 + && try(length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, false) + ]) + error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." + } + + validation { + condition = alltrue([ + for runner_config in values(var.runner_configs.runner_specs) : + !runner_config.compute_provider.ec2.user_data.debug_logging_enabled + ]) + error_message = "Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active." + } + + validation { + condition = alltrue([ + for runner_config in values(var.runner_configs.runner_specs) : + length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 + ]) + error_message = "Forge EC2 runner_specs do not support an external instance_profile." + } } variable "network_configs" { diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index 99c08bf24..21883ec28 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -8,7 +8,7 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived ## What It Manages -- EC2 runner deployment and ARC runner deployment. +- EC2 and ARC runner deployment. - GitHub App secret material in SSM Parameter Store. - IAM policies that let runners assume tenant-approved roles and pull allowed ECR images. - Runner group registration, trust validation, global lock, job log archival, webhook relay, and DLQ redrive helpers. @@ -18,7 +18,11 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived - This is the best starting point when onboarding or debugging a tenant because it shows how the platform pieces compose. - The tenant `deployment_config` defines the GitHub App, runner group, repository selection, and IAM bridge. -- The EC2 and ARC lanes can both be enabled for the same tenant; workflows choose by labels. +- EC2 and ARC lanes can be enabled for the same tenant; workflows choose by labels. +- `ec2_deployment_specs.runner_specs` now uses a breaking nested EC2 v2 shape; + every entry must configure `compute_provider.ec2`. +- `ec2_deployment` translates that public shape to the released upstream v1 + `multi_runner_config`; this phase does not enable the experimental v2 runtime. - Changing GitHub App or runner-group settings can affect job routing immediately. ## Deployment version inventory @@ -62,7 +66,7 @@ tag with the expected ref in `release_versions.yml`. | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.57.1 | +| [aws](#provider\_aws) | 6.58.0 | | [null](#provider\_null) | 3.3.0 | | [random](#provider\_random) | 3.9.0 | | [time](#provider\_time) | 0.14.0 | @@ -114,7 +118,7 @@ tag with the expected ref in `release_versions.yml`. | [aws\_region](#input\_aws\_region) | AWS region where Forge runners and supporting infrastructure are deployed. | `string` | n/a | yes | | [default\_tags](#input\_default\_tags) | A map of tags to apply to resources. | `map(string)` | n/a | yes | | [deployment\_config](#input\_deployment\_config) | High-level deployment configuration for a Forge runner installation.

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

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

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

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

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

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

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

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

compute\_provider.ec2 fields:
- ami : Upstream-compatible EC2 AMI configuration.
Forge requires a module-managed AMI block; null and
external AMI parameter ownership are unsupported.
- metadata\_options: EC2 instance metadata service configuration.
- block\_device\_mappings: EBS mappings for runner instances.
- cloudwatch\_agent/binaries\_syncer/user\_data: Runner bootstrap configuration.
user\_data.debug\_logging\_enabled must remain false while
the stable upstream v1 adapter is active.
- instance\_types and allocation fields: EC2 Fleet capacity configuration.
- vpc\_id/subnet\_ids/additional\_security\_group\_ids: Per-lane networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Upstream contract field reserved for future Forge support.
- log\_files/tags : EC2 logging and resource tags. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
runner_labels = list(string)
runner_os = string
runner_architecture = string
extra_labels = list(string)
enable_dynamic_labels = optional(bool, false)
aws_dynamic_labels_policy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
redrive_build_queue = optional(object({
enabled = optional(bool, true)
maxReceiveCount = optional(number, 10)
}), {})
max_instances = number
min_run_time = number
pool_config = list(object({
size = number
schedule_expression = string
schedule_expression_timezone = string
}))
runner_user = string
compute_provider = object({
ec2 = object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
})
})
}))
})
| n/a | yes | | [github\_webhook\_relay](#input\_github\_webhook\_relay) | Configuration for the (optional) webhook relay source module.
If enabled=true we provision the API Gateway + source EventBridge forwarding rule.
destination\_event\_bus\_name must already exist or be created in the destination account (or via the destination submodule run there). |
object({
enabled = bool
destination_account_id = optional(string)
destination_event_bus_name = optional(string)
destination_region = optional(string)
destination_reader_role_arn = optional(string)
})
|
{
"destination_account_id": "",
"destination_event_bus_name": "",
"destination_reader_role_arn": "",
"destination_region": "",
"enabled": false
}
| no | | [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | n/a | yes | | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Logging retention period in days. | `string` | n/a | yes | diff --git a/modules/platform/forge_runners/ec2_runners.tf b/modules/platform/forge_runners/ec2_runners.tf index 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 }