diff --git a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl index 50815a3c..46167e4e 100644 --- a/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl +++ b/examples/deployments/platform/terragrunt/environments/prod/regions/eu-west-1/vpcs/main/tenants/acme/runner_settings.hcl @@ -73,118 +73,131 @@ locals { "vpc:${local.vpc_alias}", "tnt:${local.tenant_name}", ] - group_name = local.runner_group_name - run_as = spec.runner_user - maximum_count = spec.max_instances - ephemeral = true + group_name = local.runner_group_name + run_as = spec.runner_user } - github = { - organization_runners = true - } - queue = { - delay_webhook_event = 0 - job_queue_retention_in_seconds = 172800 - event_source_mapping = { - batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) - } - redrive_build_queue = { - enabled = try(spec.redrive_build_queue.enabled, true) - maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) - } - } - scale_up = { - job_queued_check_enabled = false - } - scale_down = { - minimum_running_time_in_minutes = 30 - } - pool = { - config = spec.pool_config - runner_owner = local.config.gh_config.ghes_org - } - compute_provider = { - ec2 = { - metadata_options = { - http_endpoint = "enabled" - http_put_response_hop_limit = 2 - http_tokens = "optional" - instance_metadata_tags = "enabled" + orchestration_provider = { + webhook = { + runner = { + maximum_count = spec.max_instances + ephemeral = true } - ami = { - filter = { - name = [spec.ami_name] - state = ["available"] + github = { + organization_runners = true + } + lambda = { + scale = { + up = { + job_queued_check_enabled = false + event_source_mapping = { + batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + } + } + down = { + minimum_running_time_in_minutes = 30 + } } - owners = [spec.ami_owner] - kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { - arn = spec.ami_kms_key_arn + pool = { + config = spec.pool_config + runner_owner = local.config.gh_config.ghes_org } } - create_service_linked_role_spot = true - cloudwatch_agent = { - enabled = true - } - binaries_syncer = { - enabled = false + queue = { + delay_webhook_event = 0 + job_queue_retention_in_seconds = 172800 + visibility_timeout_seconds = 180 + redrive_build_queue = { + enabled = try(spec.redrive_build_queue.enabled, true) + maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) + } } - detailed_monitoring_enabled = true - ssm_enabled = true - user_data = { - enabled = true - pre_install = "# No pre-install steps." + matcherConfig = { + labelMatchers = concat( + [[ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ]], + concat([ + for label_count in range(1, 5) : concat([ + for start in range(0, 5 - label_count) : concat( + [ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ], + slice([ + "ec2", + "rgn:${local.region_alias}", + "vpc:${local.vpc_alias}", + "tnt:${local.tenant_name}", + ], start, start + label_count), + ) + ]) + ]...), + ) + exactMatch = true + enableDynamicLabels = try(spec.enable_dynamic_labels, false) + awsDynamicLabelsPolicy = try(spec.aws_dynamic_labels_policy, null) } - instance_target_capacity_type = "on-demand" - instance_types = spec.instance_types - placement = try(spec.placement, null) - license_specifications = try(spec.license_specifications, null) - use_dedicated_host = try(spec.use_dedicated_host, false) - vpc_id = try(spec.vpc_id, null) - subnet_ids = try(spec.subnet_ids, null) - scale_errors = try(spec.scale_errors, null) - block_device_mappings = [{ - delete_on_termination = true - device_name = spec.volume.device_name - encrypted = true - iops = spec.volume.iops - kms_key_id = null - snapshot_id = null - throughput = spec.volume.throughput - volume_size = spec.volume.size - volume_type = spec.volume.type - }] } } - matcherConfig = { - labelMatchers = concat( - [[ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ]], - concat([ - for label_count in range(1, 5) : concat([ - for start in range(0, 5 - label_count) : concat( - [ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ], - slice([ - "ec2", - "rgn:${local.region_alias}", - "vpc:${local.vpc_alias}", - "tnt:${local.tenant_name}", - ], start, start + label_count), - ) - ]) - ]...), - ) - exactMatch = true - enableDynamicLabels = try(spec.enable_dynamic_labels, false) - awsDynamicLabelsPolicy = try(spec.aws_dynamic_labels_policy, null) + compute_provider = { + aws = { + ec2 = { + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" + } + ami = { + filter = { + name = [spec.ami_name] + state = ["available"] + } + owners = [spec.ami_owner] + kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { + arn = spec.ami_kms_key_arn + } + } + create_service_linked_role_spot = true + cloudwatch_agent = { + enabled = true + } + binaries_syncer = { + enabled = false + } + detailed_monitoring_enabled = true + ssm_enabled = true + user_data = { + enabled = true + pre_install = "# No pre-install steps." + } + instance_target_capacity_type = "on-demand" + instance_types = spec.instance_types + placement = try(spec.placement, null) + license_specifications = try(spec.license_specifications, null) + use_dedicated_host = try(spec.use_dedicated_host, false) + vpc_id = try(spec.vpc_id, null) + subnet_ids = try(spec.subnet_ids, null) + scale_errors = try(spec.scale_errors, null) + block_device_mappings = [{ + delete_on_termination = true + device_name = spec.volume.device_name + encrypted = true + iops = spec.volume.iops + kms_key_id = null + snapshot_id = null + throughput = spec.volume.throughput + volume_size = spec.volume.size + volume_type = spec.volume.type + }] + } + } } } } diff --git a/examples/templates/platform/tenant/runner_settings.hcl b/examples/templates/platform/tenant/runner_settings.hcl index 50815a3c..46167e4e 100644 --- a/examples/templates/platform/tenant/runner_settings.hcl +++ b/examples/templates/platform/tenant/runner_settings.hcl @@ -73,118 +73,131 @@ locals { "vpc:${local.vpc_alias}", "tnt:${local.tenant_name}", ] - group_name = local.runner_group_name - run_as = spec.runner_user - maximum_count = spec.max_instances - ephemeral = true + group_name = local.runner_group_name + run_as = spec.runner_user } - github = { - organization_runners = true - } - queue = { - delay_webhook_event = 0 - job_queue_retention_in_seconds = 172800 - event_source_mapping = { - batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) - maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) - } - redrive_build_queue = { - enabled = try(spec.redrive_build_queue.enabled, true) - maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) - } - } - scale_up = { - job_queued_check_enabled = false - } - scale_down = { - minimum_running_time_in_minutes = 30 - } - pool = { - config = spec.pool_config - runner_owner = local.config.gh_config.ghes_org - } - compute_provider = { - ec2 = { - metadata_options = { - http_endpoint = "enabled" - http_put_response_hop_limit = 2 - http_tokens = "optional" - instance_metadata_tags = "enabled" + orchestration_provider = { + webhook = { + runner = { + maximum_count = spec.max_instances + ephemeral = true } - ami = { - filter = { - name = [spec.ami_name] - state = ["available"] + github = { + organization_runners = true + } + lambda = { + scale = { + up = { + job_queued_check_enabled = false + event_source_mapping = { + batch_size = try(spec.lambda_event_source_mapping_batch_size, 10) + maximum_batching_window_in_seconds = try(spec.lambda_event_source_mapping_maximum_batching_window_in_seconds, 0) + } + } + down = { + minimum_running_time_in_minutes = 30 + } } - owners = [spec.ami_owner] - kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { - arn = spec.ami_kms_key_arn + pool = { + config = spec.pool_config + runner_owner = local.config.gh_config.ghes_org } } - create_service_linked_role_spot = true - cloudwatch_agent = { - enabled = true - } - binaries_syncer = { - enabled = false + queue = { + delay_webhook_event = 0 + job_queue_retention_in_seconds = 172800 + visibility_timeout_seconds = 180 + redrive_build_queue = { + enabled = try(spec.redrive_build_queue.enabled, true) + maxReceiveCount = try(spec.redrive_build_queue.maxReceiveCount, 10) + } } - detailed_monitoring_enabled = true - ssm_enabled = true - user_data = { - enabled = true - pre_install = "# No pre-install steps." + matcherConfig = { + labelMatchers = concat( + [[ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ]], + concat([ + for label_count in range(1, 5) : concat([ + for start in range(0, 5 - label_count) : concat( + [ + "type:${spec.type}", + "self-hosted", + spec.runner_architecture, + "env:ops-${include.env.locals.env}", + ], + slice([ + "ec2", + "rgn:${local.region_alias}", + "vpc:${local.vpc_alias}", + "tnt:${local.tenant_name}", + ], start, start + label_count), + ) + ]) + ]...), + ) + exactMatch = true + enableDynamicLabels = try(spec.enable_dynamic_labels, false) + awsDynamicLabelsPolicy = try(spec.aws_dynamic_labels_policy, null) } - instance_target_capacity_type = "on-demand" - instance_types = spec.instance_types - placement = try(spec.placement, null) - license_specifications = try(spec.license_specifications, null) - use_dedicated_host = try(spec.use_dedicated_host, false) - vpc_id = try(spec.vpc_id, null) - subnet_ids = try(spec.subnet_ids, null) - scale_errors = try(spec.scale_errors, null) - block_device_mappings = [{ - delete_on_termination = true - device_name = spec.volume.device_name - encrypted = true - iops = spec.volume.iops - kms_key_id = null - snapshot_id = null - throughput = spec.volume.throughput - volume_size = spec.volume.size - volume_type = spec.volume.type - }] } } - matcherConfig = { - labelMatchers = concat( - [[ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ]], - concat([ - for label_count in range(1, 5) : concat([ - for start in range(0, 5 - label_count) : concat( - [ - "type:${spec.type}", - "self-hosted", - spec.runner_architecture, - "env:ops-${include.env.locals.env}", - ], - slice([ - "ec2", - "rgn:${local.region_alias}", - "vpc:${local.vpc_alias}", - "tnt:${local.tenant_name}", - ], start, start + label_count), - ) - ]) - ]...), - ) - exactMatch = true - enableDynamicLabels = try(spec.enable_dynamic_labels, false) - awsDynamicLabelsPolicy = try(spec.aws_dynamic_labels_policy, null) + compute_provider = { + aws = { + ec2 = { + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" + } + ami = { + filter = { + name = [spec.ami_name] + state = ["available"] + } + owners = [spec.ami_owner] + kms_key = trimspace(spec.ami_kms_key_arn) == "" ? null : { + arn = spec.ami_kms_key_arn + } + } + create_service_linked_role_spot = true + cloudwatch_agent = { + enabled = true + } + binaries_syncer = { + enabled = false + } + detailed_monitoring_enabled = true + ssm_enabled = true + user_data = { + enabled = true + pre_install = "# No pre-install steps." + } + instance_target_capacity_type = "on-demand" + instance_types = spec.instance_types + placement = try(spec.placement, null) + license_specifications = try(spec.license_specifications, null) + use_dedicated_host = try(spec.use_dedicated_host, false) + vpc_id = try(spec.vpc_id, null) + subnet_ids = try(spec.subnet_ids, null) + scale_errors = try(spec.scale_errors, null) + block_device_mappings = [{ + delete_on_termination = true + device_name = spec.volume.device_name + encrypted = true + iops = spec.volume.iops + kms_key_id = null + snapshot_id = null + throughput = spec.volume.throughput + volume_size = spec.volume.size + volume_type = spec.volume.type + }] + } + } } } } diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index d73a95a7..efa05732 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -5,42 +5,59 @@ This module deploys Forge EC2 runner pools through the upstream ## Why This Module Exists -The v2-compatible input lets each runner lane configure the common runner, -GitHub, queue, scaling, pool, retry, matcher, and EC2 provider blocks while -sharing Forge's webhook and runner control plane. Forge translates that input -to the released v1 `multi_runner_config`, so this phase does not enable -upstream's experimental v2 runtime. EC2 supports custom AMIs, macOS/Windows, -dedicated hosts, and larger hardware profiles. +The experimental input lets each runner configuration select a typed +orchestration provider and a typed compute provider. Webhook orchestration owns +runner lifecycle, capacity, startup timing, GitHub scope, label matching, +queues, scale-up, scale-down, pool, and retry settings. The common Lambda block +remains provider-neutral and owns only runtime, architecture, networking, role, +and tag overrides. Forge enriches each configuration with its required hooks, IAM +policies, bootstrap content, logging, and tags, then passes the canonical map +through `experimental.multi_runner_config`. + +Global experimental settings own the GitHub App and API client, common Lambda +substrate, webhook orchestration defaults and artifacts, the SSM housekeeper +artifact and KMS key, logging defaults, and EC2 network defaults. EC2 supports +custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. ## What It Manages - The upstream multi-runner control plane for webhook, scale-up, scale-down, and ephemeral runner registration. -- Per-lane label matching, warm pool schedules, and capacity limits. +- Per-configuration label matching, warm pool schedules, and capacity limits. - EC2 AMI, instance type, storage, user data, tag, and logging-hook configuration. - Shared KMS key material, Lambda egress security group, and EC2 AMI and tag helpers. ## Operational Notes - This is a breaking input migration: every `runner_specs` entry uses the full - nested v2 shape and must contain `runner`, `matcherConfig`, and - `compute_provider.ec2`. The legacy flat EC2 shape is not accepted. -- The common and EC2 blocks mirror the upstream v2 contract and are adapted to - stable upstream v1 fields. Forge still owns AMI - refresh and runner instance profiles, so EC2 lanes require a non-null, - module-managed `ami` block and cannot select `ami.id_ssm_parameter` or an - external `instance_profile`. The scheduled refresh uses the same default AMI - name filter as the upstream EC2 provider for each runner OS and architecture; - values in `ami.filter` override those defaults. -- Stable v1 has no per-lane user-data debug flag, so - `user_data.debug_logging_enabled` must remain `false` during this adapter - phase. -- Stable v1 also cannot represent v2's per-component tag maps, per-lane SSM KMS - key, per-lane IAM trust/path/boundary settings, or custom job-retry Lambda - concurrency. Compatibility validations reject those values instead of - silently dropping them. `compute_provider.ec2.tags` remains supported. -- Preserve runner-lane map keys when migrating configuration; those keys are - used by v1 `for_each` resources and therefore define Terraform state - addresses. + nested v2 shape and must contain `runner`, `orchestration_provider.webhook`, and + `compute_provider.aws.ec2`. The `orchestration_provider.webhook.runner` block owns the + `ephemeral` and `jit_config_enabled` lifecycle modes, `maximum_count` capacity + limit, and `boot_time_in_minutes` startup estimate. Matching, queue, + `lambda.scale.up`, `lambda.scale.down`, pool, and retry settings stay inside + that provider block. The legacy flat EC2 shape is not accepted. +- The runner-control archive is selected once at + `orchestration_provider.webhook.lambda.artifact`. Per-runner webhook Lambda blocks own + only scale and pool runtime settings and cannot select an artifact. +- The SSM housekeeper has its own artifact selector under + `ssm.housekeeper.lambda.artifact`; Forge points the global selector at the + runner control-plane archive. +- Forge still owns scheduled AMI refresh, so EC2 runner configurations require + a non-null, module-managed `ami` block and cannot select + `ami.id_ssm_parameter`. The + refresh uses the same default AMI name filter as the upstream EC2 provider for + each runner OS and architecture; values in `ami.filter` override those + defaults. +- External runner roles and instance profiles are supported as a pair. Their + owner must provision Forge's EC2-tag and hook-SSM permissions because the + module does not modify externally managed IAM resources. +- The upstream v2 ref is experimental and mutable. It does not include an + automatic state move from the prior experimental `module.runner_stacks` + address to `module.runner_configs`, or from the stable v1 graph. Migrate any + existing state explicitly and inspect plans for unexpected replacement or + naming collisions before rollout. +- Forge currently supplies the v7.10.1 Lambda release artifacts to the + experimental Terraform graph. Keep rollout gated until artifact compatibility + is verified or exact-ref artifacts are published. - Label sets are the API contract with tenant workflows, so exact matching matters. - Use warm pools only where startup latency justifies the idle cost. - Subnet IP capacity and EC2 capacity errors are expected operational signals, not unusual exceptions. @@ -59,8 +76,8 @@ dedicated hosts, and larger hardware profiles. | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | -| [external](#provider\_external) | 2.4.0 | +| [aws](#provider\_aws) | 6.60.0 | +| [external](#provider\_external) | 2.4.1 | ## Modules @@ -68,7 +85,7 @@ dedicated hosts, and larger hardware profiles. | ---- | ------ | ------- | | [ec2\_update\_runner\_ssm\_ami](#module\_ec2\_update\_runner\_ssm\_ami) | ./ec2_update_runner_ssm_ami | n/a | | [ec2\_update\_runner\_tags](#module\_ec2\_update\_runner\_tags) | ./ec2_update_runner_tags | n/a | -| [runners](#module\_runners) | git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner | v7.10.1 | +| [runners](#module\_runners) | git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner | experimental-multi-runner-config-v2-20260805 | ## Resources @@ -97,7 +114,7 @@ dedicated hosts, and larger hardware profiles. | ---- | ----------- | ---- | ------- | :------: | | [aws\_region](#input\_aws\_region) | Assuming single region for now. | `string` | n/a | yes | | [network\_configs](#input\_network\_configs) | n/a |
object({
vpc_id = string
subnet_ids = list(string)
lambda_vpc_id = string
lambda_subnet_ids = list(string)
})
| n/a | yes | -| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_specs = map(object({
tags = optional(map(string), {})

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

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

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

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

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

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

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

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

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

observability = optional(object({
logs = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

compute_provider = object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
})
| n/a | yes | +| [runner\_configs](#input\_runner\_configs) | n/a |
object({
env = string
prefix = string
ghes_url = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_specs = map(object({
tags = optional(map(string), {})

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

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

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

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

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

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

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

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

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

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

compute_provider = object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
}), {})
})

}))
})
| n/a | yes | | [tenant\_configs](#input\_tenant\_configs) | n/a |
object({
ecr_registries = list(string)
tags = map(string)
})
| n/a | yes | ## Outputs diff --git a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf index 1fc91527..e24ff1de 100644 --- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf +++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf @@ -3,9 +3,20 @@ data "aws_caller_identity" "current" {} data "aws_partition" "current" {} locals { + runner_ssm_paths = { + for key, runner_config in local.ec2_runner_configs : + key => { + root = "${trimsuffix(coalesce(runner_config.ssm.paths.root, "/github-action-runners/${var.runner_configs.prefix}"), "/")}/${key}" + config = coalesce( + runner_config.ssm.paths.config, + "runners/config", + ) + } + } + runner_ami_ssm_parameter_names = { - for key in keys(local.ec2_runner_configs) : - key => "/github-action-runners/${var.runner_configs.prefix}/${key}/runners/config/ami_id" + for key, paths in local.runner_ssm_paths : + key => "${paths.root}/${paths.config}/ami_id" } runner_ami_ssm_parameter_arns = { diff --git a/modules/platform/ec2_deployment/main.tf b/modules/platform/ec2_deployment/main.tf index 677228a9..7c653508 100644 --- a/modules/platform/ec2_deployment/main.tf +++ b/modules/platform/ec2_deployment/main.tf @@ -125,47 +125,15 @@ resource "aws_iam_policy" "runner_hooks_ssm_read" { module "runners" { - #checkov:skip=CKV_TF_1:Module source uses Renovate-managed version tags; commit SHA pinning is an accepted policy tradeoff. - source = "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=v7.10.1" + #checkov:skip=CKV_TF_1:Temporary upstream experimental ref is required to validate multi-runner v2 compatibility before a release tag exists. + source = "git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=experimental-multi-runner-config-v2-20260805" aws_region = var.aws_region - vpc_id = var.network_configs.vpc_id - subnet_ids = var.network_configs.subnet_ids - lambda_subnet_ids = var.network_configs.lambda_subnet_ids - lambda_security_group_ids = [aws_security_group.gh_runner_lambda_egress.id] - kms_key_arn = aws_kms_key.github.arn - ghes_url = var.runner_configs.ghes_url - prefix = var.runner_configs.prefix + prefix = var.runner_configs.prefix - # For authenticating against the GitHub App we created. - github_app = var.runner_configs.github_app - - eventbridge = { - enable = true - } - - lambda_tags = local.terraform_aws_github_runner_tags - tags = local.terraform_aws_github_runner_tags - parameter_store_tags = local.terraform_aws_github_runner_tags - - # Verbose logging. - log_level = var.runner_configs.log_level - - # Retention period for the logs in days. - logging_retention_in_days = var.runner_configs.logging_retention_in_days - - webhook_lambda_zip = "${data.external.download_lambdas.result.path}/webhook.zip" - webhook_lambda_apigateway_access_log_settings = { - destination_arn = aws_cloudwatch_log_group.webhook_api_gateway_access.arn - format = local.webhook_api_gateway_access_log_format - } - runner_binaries_syncer_lambda_zip = "${data.external.download_lambdas.result.path}/runner-binaries-syncer.zip" - runners_lambda_zip = "${data.external.download_lambdas.result.path}/runners.zip" - - # Temporary compatibility boundary: Forge accepts the nested v2 EC2 input - # shape, then translates it to the released upstream v1 contract. - multi_runner_config = local.multi_runner_config_v1 + multi_runner_config = {} + experimental = local.experimental_config depends_on = [ data.external.download_lambdas, diff --git a/modules/platform/ec2_deployment/outputs.tf b/modules/platform/ec2_deployment/outputs.tf index 26e11df8..fe12184d 100644 --- a/modules/platform/ec2_deployment/outputs.tf +++ b/modules/platform/ec2_deployment/outputs.tf @@ -5,7 +5,10 @@ output "webhook_endpoint" { output "ec2_runners_arn_map" { value = { - for runner_key, runner in module.runners.runners_map : runner_key => runner.role_runner[0].arn + for runner_key, runner in module.runners.runners_map_v2 : runner_key => coalesce( + try(runner.runner.role.arn, null), + try(local.ec2_runner_configs[runner_key].runner.iam.role.arn, null), + ) } description = "Map of EC2 runner keys to their IAM role ARNs." } diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf index a370d26b..72efd422 100644 --- a/modules/platform/ec2_deployment/runner_configs_v2.tf +++ b/modules/platform/ec2_deployment/runner_configs_v2.tf @@ -3,16 +3,24 @@ locals { effective_runner_users = { for key, runner_config in local.ec2_runner_configs : - key => runner_config.runner.run_as_root ? "root" : runner_config.runner.run_as + key => coalesce(runner_config.runner.run_as_root, false) ? "root" : coalesce(runner_config.runner.run_as, "ec2-user") } effective_runner_home_directories = { for key, runner_config in local.ec2_runner_configs : key => runner_config.runner.os == "windows" ? "C:/Users/Administrator" : ( - runner_config.runner.run_as_root ? (runner_config.runner.os == "osx" ? "/var/root" : "/root") : "/home/${runner_config.runner.run_as}" + coalesce(runner_config.runner.run_as_root, false) ? (runner_config.runner.os == "osx" ? "/var/root" : "/root") : "/home/${coalesce(runner_config.runner.run_as, "ec2-user")}" ) } + effective_runner_hooks = { + for key, runner_config in local.ec2_runner_configs : + key => { + job_started = runner_config.runner.hooks.job_started == null ? "" : runner_config.runner.hooks.job_started + job_completed = runner_config.runner.hooks.job_completed == null ? "" : runner_config.runner.hooks.job_completed + } + } + forge_runner_hook_job_started = { for key, runner_config in local.ec2_runner_configs : key => templatefile( @@ -37,23 +45,23 @@ locals { # Run caller hooks in a child shell so `exit` cannot bypass Forge's mandatory # lifecycle hook. Keep the empty-hook path byte-identical to the previous - # configuration to avoid state churn for migrated lanes. + # configuration to avoid state churn for migrated runner configurations. runner_hook_job_started = { for key, runner_config in local.ec2_runner_configs : - key => runner_config.runner.hooks.job_started == "" ? local.forge_runner_hook_job_started[key] : join("\n", [ + key => local.effective_runner_hooks[key].job_started == "" ? local.forge_runner_hook_job_started[key] : join("\n", [ runner_config.runner.os == "windows" ? - "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${textencodebase64(runner_config.runner.hooks.job_started, "UTF-16LE")}" : - "printf '%s' '${base64encode(runner_config.runner.hooks.job_started)}' | base64 --decode | bash", + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${textencodebase64(local.effective_runner_hooks[key].job_started, "UTF-16LE")}" : + "printf '%s' '${base64encode(local.effective_runner_hooks[key].job_started)}' | base64 --decode | bash", local.forge_runner_hook_job_started[key], ]) } runner_hook_job_completed = { for key, runner_config in local.ec2_runner_configs : - key => runner_config.runner.hooks.job_completed == "" ? local.forge_runner_hook_job_completed[key] : join("\n", [ + key => local.effective_runner_hooks[key].job_completed == "" ? local.forge_runner_hook_job_completed[key] : join("\n", [ runner_config.runner.os == "windows" ? - "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${textencodebase64(runner_config.runner.hooks.job_completed, "UTF-16LE")}" : - "printf '%s' '${base64encode(runner_config.runner.hooks.job_completed)}' | base64 --decode | bash", + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${textencodebase64(local.effective_runner_hooks[key].job_completed, "UTF-16LE")}" : + "printf '%s' '${base64encode(local.effective_runner_hooks[key].job_completed)}' | base64 --decode | bash", local.forge_runner_hook_job_completed[key], ]) } @@ -65,7 +73,7 @@ locals { active_ec2_subnet_ids = toset(flatten([ for runner_config in values(local.ec2_runner_configs) : - runner_config.compute_provider.ec2.subnet_ids == null ? var.network_configs.subnet_ids : runner_config.compute_provider.ec2.subnet_ids + runner_config.compute_provider.aws.ec2.subnet_ids == null ? var.network_configs.subnet_ids : runner_config.compute_provider.aws.ec2.subnet_ids ])) # This is the upstream EC2 provider's default AMI selection. Normalize it @@ -83,8 +91,8 @@ locals { legacy_runner_labels = { for key, runner_config in local.ec2_runner_configs : key => concat( - try(runner_config.matcherConfig.labelMatchers[0], []), - runner_config.runner.extra_labels, + try(runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers[0], []), + coalesce(runner_config.runner.extra_labels, []), ) } @@ -97,7 +105,7 @@ locals { local.legacy_runner_labels[key], distinct([ for label in flatten([ - for matcher_index, labels in runner_config.matcherConfig.labelMatchers : labels if matcher_index > 0 + for matcher_index, labels in runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers : labels if matcher_index > 0 ]) : label if !contains(local.legacy_runner_labels[key], label) ]), @@ -141,26 +149,26 @@ locals { ec2_compute_provider = { for key, runner_config in local.ec2_runner_configs : key => merge( - runner_config.compute_provider.ec2, + runner_config.compute_provider.aws.ec2, { - ami = runner_config.compute_provider.ec2.ami == null ? null : merge( - runner_config.compute_provider.ec2.ami, + ami = runner_config.compute_provider.aws.ec2.ami == null ? null : merge( + runner_config.compute_provider.aws.ec2.ami, { filter = merge( local.ec2_default_ami_filters[key], - runner_config.compute_provider.ec2.ami.filter, + runner_config.compute_provider.aws.ec2.ami.filter, ) } ) user_data = merge( - runner_config.compute_provider.ec2.user_data, + runner_config.compute_provider.aws.ec2.user_data, { template = ( - runner_config.compute_provider.ec2.user_data.content == null - && runner_config.compute_provider.ec2.user_data.template == null - ) ? "${local.user_data_prefix}/user_data_${runner_config.runner.os}.tftpl" : runner_config.compute_provider.ec2.user_data.template + runner_config.compute_provider.aws.ec2.user_data.content == null + && runner_config.compute_provider.aws.ec2.user_data.template == null + ) ? "${local.user_data_prefix}/user_data_${runner_config.runner.os}.tftpl" : runner_config.compute_provider.aws.ec2.user_data.template post_install = join("\n", compact([ - runner_config.compute_provider.ec2.user_data.post_install, + runner_config.compute_provider.aws.ec2.user_data.post_install, templatefile( local.userdata_template_post_install, { @@ -172,110 +180,121 @@ locals { ])) } ) - log_files = coalesce(runner_config.compute_provider.ec2.log_files, local.forge_ec2_log_files[key]) - tags = merge(var.tenant_configs.tags, runner_config.compute_provider.ec2.tags) + log_files = coalesce(runner_config.compute_provider.aws.ec2.log_files, local.forge_ec2_log_files[key]) + tags = merge(var.tenant_configs.tags, runner_config.compute_provider.aws.ec2.tags) } ) } - # Keep Forge's public input aligned with the nested v2 EC2 contract while - # the upstream module remains on its stable v1 multi_runner_config path. - multi_runner_config_v1 = { + # Preserve Forge-managed runner hooks, policies, bootstrap content, logging, + # and tags while passing the public nested contract directly to upstream v2. + multi_runner_config = { for key, runner_config in local.ec2_runner_configs : - key => { - runner_config = { - runner_os = runner_config.runner.os - runner_architecture = runner_config.runner.architecture - runner_metadata_options = local.ec2_compute_provider[key].metadata_options - runner_boot_time_in_minutes = runner_config.runner.boot_time_in_minutes - runner_disable_default_labels = runner_config.runner.disable_default_labels - runner_extra_labels = runner_config.runner.extra_labels - runner_group_name = runner_config.runner.group_name - runner_name_prefix = runner_config.runner.name_prefix - runner_as_root = runner_config.runner.run_as_root - runner_run_as = runner_config.runner.run_as - runners_maximum_count = runner_config.runner.maximum_count - enable_ephemeral_runners = runner_config.runner.ephemeral - enable_jit_config = runner_config.runner.jit_config_enabled - disable_runner_autoupdate = runner_config.runner.auto_update_disabled - enable_organization_runners = runner_config.github.organization_runners - - ami = { - filter = local.ec2_compute_provider[key].ami.filter - owners = local.ec2_compute_provider[key].ami.owners - id_ssm_parameter_arn = try(local.ec2_compute_provider[key].ami.id_ssm_parameter.arn, null) - kms_key_arn = try(local.ec2_compute_provider[key].ami.kms_key.arn, null) + key => merge(runner_config, { + runner = merge(runner_config.runner, { + hooks = { + job_started = local.runner_hook_job_started[key] + job_completed = local.runner_hook_job_completed[key] + } + iam = merge(runner_config.runner.iam, { + managed_policy_arns = merge( + coalesce(runner_config.runner.iam.managed_policy_arns, {}), + runner_config.runner.iam.role == null ? merge( + { + for policy_index, policy_arn in var.runner_configs.runner_iam_role_managed_policy_arns : + "forge-config-${policy_index}" => policy_arn + }, + { + forge_ec2_tags = aws_iam_policy.ec2_tags.arn + forge_runner_hooks_ssm_read = aws_iam_policy.runner_hooks_ssm_read.arn + }, + ) : {}, + ) + }) + }) + compute_provider = { + aws = { + ec2 = local.ec2_compute_provider[key] } + } + }) + } + + experimental_config = { + tags = local.terraform_aws_github_runner_tags + + github = { + app = var.runner_configs.github_app + enterprise_server = { + url = try(trimspace(var.runner_configs.ghes_url), "") == "" ? null : var.runner_configs.ghes_url + } + } - block_device_mappings = local.ec2_compute_provider[key].block_device_mappings - create_service_linked_role_spot = local.ec2_compute_provider[key].create_service_linked_role_spot - credit_specification = local.ec2_compute_provider[key].credit_specification - ebs_optimized = local.ec2_compute_provider[key].ebs_optimized - enable_cloudwatch_agent = local.ec2_compute_provider[key].cloudwatch_agent.enabled - cloudwatch_config = local.ec2_compute_provider[key].cloudwatch_agent.config - enable_runner_binaries_syncer = local.ec2_compute_provider[key].binaries_syncer.enabled - enable_runner_detailed_monitoring = local.ec2_compute_provider[key].detailed_monitoring_enabled - enable_ssm_on_runners = local.ec2_compute_provider[key].ssm_enabled - enable_userdata = local.ec2_compute_provider[key].user_data.enabled - userdata_template = local.ec2_compute_provider[key].user_data.template - userdata_content = local.ec2_compute_provider[key].user_data.content - userdata_pre_install = local.ec2_compute_provider[key].user_data.pre_install - userdata_post_install = local.ec2_compute_provider[key].user_data.post_install - instance_allocation_strategy = local.ec2_compute_provider[key].instance_allocation_strategy - instance_max_spot_price = local.ec2_compute_provider[key].instance_max_spot_price - instance_target_capacity_type = local.ec2_compute_provider[key].instance_target_capacity_type - instance_type_priorities = local.ec2_compute_provider[key].instance_type_priorities - instance_types = local.ec2_compute_provider[key].instance_types - runner_additional_security_group_ids = local.ec2_compute_provider[key].additional_security_group_ids - enable_on_demand_failover_for_errors = local.ec2_compute_provider[key].enable_on_demand_failover_for_errors - scale_errors = local.ec2_compute_provider[key].scale_errors - subnet_ids = local.ec2_compute_provider[key].subnet_ids - vpc_id = local.ec2_compute_provider[key].vpc_id - cpu_options = local.ec2_compute_provider[key].cpu_options - placement = local.ec2_compute_provider[key].placement - license_specifications = local.ec2_compute_provider[key].license_specifications - use_dedicated_host = local.ec2_compute_provider[key].use_dedicated_host - runner_log_files = local.ec2_compute_provider[key].log_files - runner_ec2_tags = local.ec2_compute_provider[key].tags + lambda = { + subnet_ids = var.network_configs.lambda_subnet_ids + security_group_ids = [aws_security_group.gh_runner_lambda_egress.id] + tags = local.terraform_aws_github_runner_tags + } - delay_webhook_event = runner_config.queue.delay_webhook_event - job_queue_retention_in_seconds = runner_config.queue.job_queue_retention_in_seconds - lambda_event_source_mapping_batch_size = runner_config.queue.event_source_mapping.batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = runner_config.queue.event_source_mapping.maximum_batching_window_in_seconds - scale_up_reserved_concurrent_executions = runner_config.scale_up.reserved_concurrent_executions - enable_job_queued_check = runner_config.scale_up.job_queued_check_enabled - scale_down_schedule_expression = runner_config.scale_down.schedule_expression - minimum_running_time_in_minutes = runner_config.scale_down.minimum_running_time_in_minutes - idle_config = runner_config.scale_down.idle_config - pool_config = runner_config.pool.config - pool_runner_owner = runner_config.pool.runner_owner - job_retry = { - enable = runner_config.job_retry.enabled - delay_in_seconds = runner_config.job_retry.delay_in_seconds - delay_backoff = runner_config.job_retry.delay_backoff - lambda_memory_size = runner_config.job_retry.lambda.memory_size - lambda_timeout = runner_config.job_retry.lambda.timeout - max_attempts = runner_config.job_retry.max_attempts + orchestration_provider = { + webhook = { + eventbridge = { + enable = true } + lambda = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/runners.zip" + } + webhook = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/webhook.zip" + } + api_gateway_access_log_settings = { + destination_arn = aws_cloudwatch_log_group.webhook_api_gateway_access.arn + format = local.webhook_api_gateway_access_log_format + } + } + } + } + } - runner_hook_job_started = local.runner_hook_job_started[key] - runner_hook_job_completed = local.runner_hook_job_completed[key] - runner_iam_role_managed_policy_arns = concat( - var.runner_configs.runner_iam_role_managed_policy_arns, - [ - aws_iam_policy.ec2_tags.arn, - aws_iam_policy.runner_hooks_ssm_read.arn, - ], - [ - for policy_name in sort(keys(runner_config.runner.iam.managed_policy_arns)) : - runner_config.runner.iam.managed_policy_arns[policy_name] - ], - ) + ssm = { + kms_key_id = aws_kms_key.github.arn + parameters = { + tags = local.terraform_aws_github_runner_tags + } + housekeeper = { + lambda = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/runners.zip" + } + } } + } - matcherConfig = runner_config.matcherConfig + observability = { + logs = { + level = lower(var.runner_configs.log_level) + retention_in_days = tonumber(var.runner_configs.logging_retention_in_days) + } + } - redrive_build_queue = runner_config.queue.redrive_build_queue + compute_provider = { + aws = { + ec2 = { + vpc_id = var.network_configs.vpc_id + subnet_ids = var.network_configs.subnet_ids + runner_binaries = { + syncer = { + artifact = { + zip = "${data.external.download_lambdas.result.path}/runner-binaries-syncer.zip" + } + } + } + } + } } + + multi_runner_config = local.multi_runner_config } } diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl index 63eaa876..847b3c44 100644 --- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl @@ -126,20 +126,18 @@ variables { runner_iam_role_managed_policy_arns = [] runner_specs = { ec2 = { + tags = { Scope = "entry" } runner = { os = "linux" architecture = "x64" - boot_time_in_minutes = 7 disable_default_labels = true extra_labels = ["caller-extra"] group_name = "Forge" name_prefix = "forge-" run_as_root = true run_as = "ec2-user" - maximum_count = 2 - ephemeral = true - jit_config_enabled = false auto_update_disabled = true + tags = { Scope = "runner" } hooks = { job_started = "echo caller-started\nexit 0" job_completed = "echo caller-completed" @@ -148,117 +146,256 @@ variables { managed_policy_arns = { caller = "arn:aws:iam::123456789012:policy/caller" } + additional_trust_policy_json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + path = "/forge/" + permissions_boundary = "arn:aws:iam::123456789012:policy/boundary" } } - github = { - organization_runners = true - } - queue = { - delay_webhook_event = 7 - job_queue_retention_in_seconds = 90000 - event_source_mapping = { - batch_size = 5 - maximum_batching_window_in_seconds = 1 - } - redrive_build_queue = { - enabled = true - maxReceiveCount = 4 + lambda = { + runtime = "nodejs22.x" + architecture = "x86_64" + subnet_ids = ["subnet-lambda-override"] + security_group_ids = ["sg-lambda"] + tags = { Scope = "lambda" } + role = { + path = "/lambda/" + permissions_boundary = "arn:aws:iam::123456789012:policy/lambda-boundary" } } - scale_up = { - reserved_concurrent_executions = 2 - job_queued_check_enabled = false - } - scale_down = { - schedule_expression = "rate(10 minutes)" - minimum_running_time_in_minutes = 5 - idle_config = [{ - cron = "* * * * *" - timeZone = "Europe/Warsaw" - idleCount = 1 - evictionStrategy = "newest_first" - }] + + orchestration_provider = { + webhook = { + runner = { + boot_time_in_minutes = 7 + ephemeral = true + jit_config_enabled = false + maximum_count = 2 + } + github = { + organization_runners = true + } + lambda = { + scale = { + up = { + memory_size = 768 + timeout = 40 + reserved_concurrent_executions = 2 + job_queued_check_enabled = false + event_source_mapping = { + batch_size = 5 + maximum_batching_window_in_seconds = 1 + } + tags = { Scope = "scale-up" } + } + down = { + memory_size = 1024 + timeout = 90 + schedule_expression = "rate(10 minutes)" + minimum_running_time_in_minutes = 5 + idle_config = [{ + cron = "* * * * *" + timeZone = "Europe/Warsaw" + idleCount = 1 + evictionStrategy = "newest_first" + }] + tags = { Scope = "scale-down" } + } + } + pool = { + memory_size = 512 + timeout = 75 + reserved_concurrent_executions = 2 + config = [{ + schedule_expression = "cron(0 8 * * ? *)" + schedule_expression_timezone = "Europe/Warsaw" + size = 1 + }] + include_busy_runners = false + runner_owner = "cisco-open" + tags = { Scope = "pool" } + } + } + queue = { + delay_webhook_event = 7 + job_queue_retention_in_seconds = 90000 + visibility_timeout_seconds = 240 + redrive_build_queue = { + enabled = true + maxReceiveCount = 4 + } + tags = { Scope = "queue" } + } + job_retry = { + enabled = true + delay_in_seconds = 120 + delay_backoff = 3 + max_attempts = 2 + tags = { Scope = "job-retry" } + lambda = { + memory_size = 512 + reserved_concurrent_executions = 3 + timeout = 45 + } + } + matcherConfig = { + labelMatchers = [["self-hosted", "ec2"], ["self-hosted", "gpu"]] + exactMatch = true + bidirectionalLabelMatch = true + priority = 5 + enableDynamicLabels = true + awsDynamicLabelsPolicy = { + blocked_keys = ["instance-type"] + } + } + } } - pool = { - config = [{ - schedule_expression = "cron(0 8 * * ? *)" - schedule_expression_timezone = "Europe/Warsaw" - size = 1 - }] - runner_owner = "cisco-open" + ssm = { + tags = { Scope = "ssm" } + paths = { + root = "/custom-root" + tokens = "custom-tokens" + config = "custom-config" + } + parameters = { + tags = { Scope = "ssm-parameters" } + } + housekeeper = { + schedule_expression = "rate(12 hours)" + state = "DISABLED" + tags = { Scope = "ssm-housekeeper" } + lambda = { + artifact = { + zip = "/private/tmp/forge-test-lambda-cache/ssm-housekeeper.zip" + } + memory_size = 384 + timeout = 70 + } + config = { + tokenPath = "custom-token-path" + minimumDaysOld = 2 + dryRun = true + } + } } - job_retry = { - enabled = true - delay_in_seconds = 120 - delay_backoff = 3 - max_attempts = 2 - lambda = { - memory_size = 512 - timeout = 45 + observability = { + logs = { + level = "debug" + retention_in_days = 14 + kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/22222222-2222-2222-2222-222222222222" + class = "INFREQUENT_ACCESS" + tags = { Scope = "logs" } + } + tracing = { + mode = "Active" + capture_http_requests = false + capture_error = false + } + metrics = { + enable = false + namespace = "Forge/Test" + metric = { + enable_github_app_rate_limit = false + enable_job_retry = true + } } } compute_provider = { - ec2 = { - metadata_options = { - http_endpoint = "enabled" - http_put_response_hop_limit = 2 - http_tokens = "optional" - instance_metadata_tags = "enabled" - } - ami = { - filter = { - name = ["forge-*"] - state = ["available"] + aws = { + ec2 = { + metadata_options = { + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + http_tokens = "optional" + instance_metadata_tags = "enabled" } - owners = ["123456789012"] - kms_key = { - arn = "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + ami = { + filter = { + name = ["forge-*"] + state = ["available"] + } + owners = ["123456789012"] + kms_key = { + arn = "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + } } + cloudwatch_agent = { + enabled = true + config = "{\"agent\":{}}" + } + binaries_syncer = { + enabled = false + } + detailed_monitoring_enabled = true + ebs_optimized = true + instance_allocation_strategy = "prioritized" + instance_type_priorities = { "m7i.large" = 1 } + instance_types = ["m7i.large"] + instance_target_capacity_type = "on-demand" + additional_security_group_ids = ["sg-runner"] + managed_security_group_enabled = false + egress_rules = [{ + cidr_blocks = ["10.0.0.0/8"] + ipv6_cidr_blocks = [] + prefix_list_ids = [] + from_port = 443 + protocol = "tcp" + security_groups = [] + self = false + to_port = 443 + description = "HTTPS" + }] + instance_profile_path = "/runner/" + key_name = "forge-test" + associate_public_ipv4_address = false + scale_errors = ["InsufficientInstanceCapacity"] + ssm_enabled = true + subnet_ids = ["subnet-override"] + tags = { Configuration = "ec2" } + user_data = { + enabled = true + pre_install = "caller-pre" + post_install = "caller-post" + debug_logging_enabled = true + } + block_device_mappings = [{ + delete_on_termination = true + device_name = "/dev/xvda" + encrypted = true + iops = 3000 + kms_key_id = null + snapshot_id = null + throughput = 125 + volume_size = 30 + volume_type = "gp3" + }] } - cloudwatch_agent = { - enabled = true - config = "{\"agent\":{}}" - } - binaries_syncer = { - enabled = false + } + } + } + default_path = { + runner = { + os = "linux" + architecture = "x64" + } + orchestration_provider = { + webhook = { + runner = { + maximum_count = 1 } - detailed_monitoring_enabled = true - ebs_optimized = true - instance_allocation_strategy = "prioritized" - instance_type_priorities = { "m7i.large" = 1 } - instance_types = ["m7i.large"] - instance_target_capacity_type = "on-demand" - additional_security_group_ids = ["sg-runner"] - scale_errors = ["InsufficientInstanceCapacity"] - ssm_enabled = true - subnet_ids = ["subnet-override"] - tags = { Lane = "ec2" } - user_data = { - enabled = true - pre_install = "caller-pre" - post_install = "caller-post" + matcherConfig = { + labelMatchers = [["self-hosted", "default-path"]] } - block_device_mappings = [{ - delete_on_termination = true - device_name = "/dev/xvda" - encrypted = true - iops = 3000 - kms_key_id = null - snapshot_id = null - throughput = 125 - volume_size = 30 - volume_type = "gp3" - }] } } - matcherConfig = { - labelMatchers = [["self-hosted", "ec2"], ["self-hosted", "gpu"]] - exactMatch = true - bidirectionalLabelMatch = true - priority = 5 - enableDynamicLabels = true - awsDynamicLabelsPolicy = { - blocked_keys = ["instance-type"] + compute_provider = { + aws = { + ec2 = { + ami = {} + instance_types = ["m7i.large"] + binaries_syncer = { + enabled = false + } + } } } } @@ -266,7 +403,7 @@ variables { } } -run "ec2_v2_input_v1_adapter_plan" { +run "ec2_v2_input_plan" { command = plan plan_options { @@ -277,104 +414,144 @@ run "ec2_v2_input_v1_adapter_plan" { } assert { - condition = toset(keys(local.ec2_runner_configs)) == toset(["ec2"]) - error_message = "EC2 provider filtering must retain every EC2 lane." + condition = toset(keys(local.ec2_runner_configs)) == toset(["default_path", "ec2"]) + error_message = "EC2 provider filtering must retain every EC2 runner configuration." + } + + assert { + condition = toset(keys(local.multi_runner_config)) == toset(["default_path", "ec2"]) + error_message = "The upstream v2 configuration must preserve every EC2 runner-configuration key." + } + + assert { + condition = ( + toset(keys(local.multi_runner_config.ec2.compute_provider)) == toset(["aws"]) + && toset(keys(local.multi_runner_config.ec2.compute_provider.aws)) == toset(["ec2"]) + ) + error_message = "Forge must preserve the AWS namespace while keeping ec2 as the runtime compute-provider type." } assert { - condition = toset(keys(local.multi_runner_config_v1)) == toset(["ec2"]) - error_message = "The stable v1 adapter must preserve every EC2 lane key." + condition = alltrue([ + for runner_config in values(local.multi_runner_config) : + length([ + for provider_config in values(runner_config.orchestration_provider) : provider_config + if provider_config != null + ]) == 1 + ]) + error_message = "Every normalized runner configuration must retain exactly one orchestration provider." } assert { - condition = local.active_ec2_subnet_ids == toset(["subnet-override"]) - error_message = "EC2 effective subnet resolution must preserve per-lane overrides." + condition = local.active_ec2_subnet_ids == toset(["subnet-default", "subnet-override"]) + error_message = "EC2 effective subnet resolution must preserve per-configuration overrides." } assert { condition = ( - tolist(local.multi_runner_config_v1.ec2.runner_config.ami.filter.name) == tolist(["forge-*"]) - && local.multi_runner_config_v1.ec2.runner_config.ami.id_ssm_parameter_arn == null - && local.multi_runner_config_v1.ec2.runner_config.ami.kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" - && local.multi_runner_config_v1.ec2.runner_config.ebs_optimized + tolist(local.multi_runner_config.ec2.compute_provider.aws.ec2.ami.filter.name) == tolist(["forge-*"]) + && local.multi_runner_config.ec2.compute_provider.aws.ec2.ami.id_ssm_parameter == null + && local.multi_runner_config.ec2.compute_provider.aws.ec2.ami.kms_key.arn == "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.ebs_optimized ) - error_message = "The v1 adapter must flatten the nested EC2 AMI and fleet configuration." + error_message = "The v2 configuration must preserve nested EC2 AMI and fleet configuration." } assert { condition = ( - local.multi_runner_config_v1.ec2.runner_config.runner_metadata_options.http_tokens == "optional" - && local.multi_runner_config_v1.ec2.runner_config.runner_metadata_options.http_put_response_hop_limit == 2 - && local.multi_runner_config_v1.ec2.runner_config.enable_cloudwatch_agent - && local.multi_runner_config_v1.ec2.runner_config.cloudwatch_config == "{\"agent\":{}}" - && !local.multi_runner_config_v1.ec2.runner_config.enable_runner_binaries_syncer - && local.multi_runner_config_v1.ec2.runner_config.enable_runner_detailed_monitoring - && local.multi_runner_config_v1.ec2.runner_config.enable_ssm_on_runners - && tolist(local.multi_runner_config_v1.ec2.runner_config.runner_additional_security_group_ids) == tolist(["sg-runner"]) + local.multi_runner_config.ec2.compute_provider.aws.ec2.metadata_options.http_tokens == "optional" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.metadata_options.http_put_response_hop_limit == 2 + && local.multi_runner_config.ec2.compute_provider.aws.ec2.cloudwatch_agent.enabled + && local.multi_runner_config.ec2.compute_provider.aws.ec2.cloudwatch_agent.config == "{\"agent\":{}}" + && !local.multi_runner_config.ec2.compute_provider.aws.ec2.binaries_syncer.enabled + && local.multi_runner_config.ec2.compute_provider.aws.ec2.detailed_monitoring_enabled + && local.multi_runner_config.ec2.compute_provider.aws.ec2.ssm_enabled + && tolist(local.multi_runner_config.ec2.compute_provider.aws.ec2.additional_security_group_ids) == tolist(["sg-runner"]) + && !local.multi_runner_config.ec2.compute_provider.aws.ec2.managed_security_group_enabled + && local.multi_runner_config.ec2.compute_provider.aws.ec2.egress_rules[0].description == "HTTPS" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.instance_profile_path == "/runner/" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.key_name == "forge-test" + && !local.multi_runner_config.ec2.compute_provider.aws.ec2.associate_public_ipv4_address ) - error_message = "The v1 adapter must preserve nested EC2 bootstrap, metadata, and networking settings." + error_message = "The v2 configuration must preserve nested EC2 bootstrap, metadata, and networking settings." } assert { condition = ( - local.multi_runner_config_v1.ec2.runner_config.runner_boot_time_in_minutes == 7 - && local.multi_runner_config_v1.ec2.runner_config.runner_disable_default_labels - && tolist(local.multi_runner_config_v1.ec2.runner_config.runner_extra_labels) == tolist(["caller-extra"]) - && local.multi_runner_config_v1.ec2.runner_config.runner_group_name == "Forge" - && local.multi_runner_config_v1.ec2.runner_config.runner_name_prefix == "forge-" - && local.multi_runner_config_v1.ec2.runner_config.runner_as_root - && local.multi_runner_config_v1.ec2.runner_config.runner_run_as == "ec2-user" - && local.multi_runner_config_v1.ec2.runner_config.runners_maximum_count == 2 - && local.multi_runner_config_v1.ec2.runner_config.enable_ephemeral_runners - && !local.multi_runner_config_v1.ec2.runner_config.enable_jit_config - && local.multi_runner_config_v1.ec2.runner_config.disable_runner_autoupdate - && local.multi_runner_config_v1.ec2.runner_config.enable_organization_runners + local.multi_runner_config.ec2.runner.disable_default_labels + && tolist(local.multi_runner_config.ec2.runner.extra_labels) == tolist(["caller-extra"]) + && local.multi_runner_config.ec2.runner.group_name == "Forge" + && local.multi_runner_config.ec2.runner.name_prefix == "forge-" + && local.multi_runner_config.ec2.runner.run_as_root + && local.multi_runner_config.ec2.runner.run_as == "ec2-user" + && local.multi_runner_config.ec2.orchestration_provider.webhook.runner.boot_time_in_minutes == 7 + && local.multi_runner_config.ec2.orchestration_provider.webhook.runner.ephemeral + && !local.multi_runner_config.ec2.orchestration_provider.webhook.runner.jit_config_enabled + && local.multi_runner_config.ec2.orchestration_provider.webhook.runner.maximum_count == 2 + && local.multi_runner_config.ec2.runner.auto_update_disabled + && local.multi_runner_config.ec2.orchestration_provider.webhook.github.organization_runners ) - error_message = "The v1 adapter must translate the complete nested runner and GitHub blocks." + error_message = "The v2 configuration must preserve the common runner and webhook-owned runner and GitHub blocks." } assert { condition = ( - local.multi_runner_config_v1.ec2.runner_config.delay_webhook_event == 7 - && local.multi_runner_config_v1.ec2.runner_config.job_queue_retention_in_seconds == 90000 - && local.multi_runner_config_v1.ec2.runner_config.lambda_event_source_mapping_batch_size == 5 - && local.multi_runner_config_v1.ec2.runner_config.lambda_event_source_mapping_maximum_batching_window_in_seconds == 1 - && local.multi_runner_config_v1.ec2.runner_config.scale_up_reserved_concurrent_executions == 2 - && !local.multi_runner_config_v1.ec2.runner_config.enable_job_queued_check - && local.multi_runner_config_v1.ec2.runner_config.scale_down_schedule_expression == "rate(10 minutes)" - && local.multi_runner_config_v1.ec2.runner_config.minimum_running_time_in_minutes == 5 - && local.multi_runner_config_v1.ec2.runner_config.idle_config[0].idleCount == 1 - && local.multi_runner_config_v1.ec2.redrive_build_queue.maxReceiveCount == 4 + local.multi_runner_config.ec2.orchestration_provider.webhook.queue.delay_webhook_event == 7 + && local.multi_runner_config.ec2.orchestration_provider.webhook.queue.job_queue_retention_in_seconds == 90000 + && local.multi_runner_config.ec2.orchestration_provider.webhook.queue.visibility_timeout_seconds == 240 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.batch_size == 5 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds == 1 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.memory_size == 768 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.timeout == 40 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.reserved_concurrent_executions == 2 + && !local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.memory_size == 1024 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.timeout == 90 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.schedule_expression == "rate(10 minutes)" + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.minimum_running_time_in_minutes == 5 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.idle_config[0].idleCount == 1 + && local.multi_runner_config.ec2.orchestration_provider.webhook.queue.redrive_build_queue.maxReceiveCount == 4 ) - error_message = "The v1 adapter must translate the queue, scale-up, and scale-down blocks." + error_message = "The v2 configuration must preserve the queue, scale-up, and scale-down blocks." } assert { condition = ( - local.multi_runner_config_v1.ec2.runner_config.pool_config[0].size == 1 - && local.multi_runner_config_v1.ec2.runner_config.pool_runner_owner == "cisco-open" - && local.multi_runner_config_v1.ec2.runner_config.job_retry.enable - && local.multi_runner_config_v1.ec2.runner_config.job_retry.delay_in_seconds == 120 - && local.multi_runner_config_v1.ec2.runner_config.job_retry.delay_backoff == 3 - && local.multi_runner_config_v1.ec2.runner_config.job_retry.max_attempts == 2 - && local.multi_runner_config_v1.ec2.runner_config.job_retry.lambda_memory_size == 512 - && local.multi_runner_config_v1.ec2.runner_config.job_retry.lambda_timeout == 45 + local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.config[0].size == 1 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.memory_size == 512 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.timeout == 75 + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.reserved_concurrent_executions == 2 + && !local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.include_busy_runners + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.runner_owner == "cisco-open" + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.enabled + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.delay_in_seconds == 120 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.delay_backoff == 3 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.max_attempts == 2 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.lambda.memory_size == 512 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.lambda.reserved_concurrent_executions == 3 + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.lambda.timeout == 45 ) - error_message = "The v1 adapter must translate the pool and job-retry blocks." + error_message = "The v2 configuration must preserve the pool and job-retry blocks." + } + + assert { + condition = !can(local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.artifact) + error_message = "Runner-control artifacts must be configured globally, not in a per-runner webhook Lambda block." } assert { condition = ( - local.multi_runner_config_v1.ec2.runner_config.userdata_pre_install == "caller-pre" - && startswith(local.multi_runner_config_v1.ec2.runner_config.userdata_post_install, "caller-post\n") - && strcontains(local.multi_runner_config_v1.ec2.runner_config.userdata_post_install, "su -l root -c") - && strcontains(local.multi_runner_config_v1.ec2.runner_config.userdata_post_install, "--config /root/.docker") - && length(local.multi_runner_config_v1.ec2.runner_config.runner_log_files) == 4 - && local.multi_runner_config_v1.ec2.runner_config.runner_log_files[3].file_path == "/root/hook.log" - && local.multi_runner_config_v1.ec2.runner_config.runner_ec2_tags.Environment == "test" - && local.multi_runner_config_v1.ec2.runner_config.runner_ec2_tags.Lane == "ec2" + local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.pre_install == "caller-pre" + && startswith(local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.post_install, "caller-post\n") + && strcontains(local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.post_install, "su -l root -c") + && strcontains(local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.post_install, "--config /root/.docker") + && local.multi_runner_config.ec2.compute_provider.aws.ec2.user_data.debug_logging_enabled + && length(local.multi_runner_config.ec2.compute_provider.aws.ec2.log_files) == 4 + && local.multi_runner_config.ec2.compute_provider.aws.ec2.log_files[3].file_path == "/root/hook.log" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.tags.Environment == "test" + && local.multi_runner_config.ec2.compute_provider.aws.ec2.tags.Configuration == "ec2" ) - error_message = "The v1 adapter must retain Forge user-data, logging, and tag overlays." + error_message = "The v2 configuration must retain Forge user-data, logging, and tag overlays." } assert { @@ -389,29 +566,143 @@ run "ec2_v2_input_v1_adapter_plan" { assert { condition = ( startswith( - local.multi_runner_config_v1.ec2.runner_config.runner_hook_job_started, + local.multi_runner_config.ec2.runner.hooks.job_started, "printf '%s' '${base64encode("echo caller-started\nexit 0")}' | base64 --decode | bash\n", ) && startswith( - local.multi_runner_config_v1.ec2.runner_config.runner_hook_job_completed, + local.multi_runner_config.ec2.runner.hooks.job_completed, "printf '%s' '${base64encode("echo caller-completed")}' | base64 --decode | bash\n", ) - && local.multi_runner_config_v1.ec2.runner_config.runner_iam_role_managed_policy_arns[2] == "arn:aws:iam::123456789012:policy/caller" + && local.multi_runner_config.ec2.runner.iam.managed_policy_arns.caller == "arn:aws:iam::123456789012:policy/caller" + && local.multi_runner_config.ec2.runner.iam.managed_policy_arns.forge_ec2_tags == "arn:aws:iam::123456789012:policy/mock" + && local.multi_runner_config.ec2.runner.iam.managed_policy_arns.forge_runner_hooks_ssm_read == "arn:aws:iam::123456789012:policy/mock" + ) + error_message = "Caller hooks must be isolated from Forge's required lifecycle hooks, and Forge policies must augment caller policies." + } + + assert { + condition = ( + length(local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.labelMatchers) == 2 + && tolist(local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) + && tolist(local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) + && local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.exactMatch + && local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.bidirectionalLabelMatch + && local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.priority == 5 + && local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.enableDynamicLabels + && tolist(local.multi_runner_config.ec2.orchestration_provider.webhook.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) ) - error_message = "Caller hooks must be isolated from Forge's required lifecycle hooks, and caller policies must be appended." + error_message = "The v2 configuration must preserve matcher configuration." } assert { condition = ( - length(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers) == 2 - && tolist(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "ec2"]) - && tolist(local.multi_runner_config_v1.ec2.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "gpu"]) - && local.multi_runner_config_v1.ec2.matcherConfig.exactMatch - && local.multi_runner_config_v1.ec2.matcherConfig.bidirectionalLabelMatch - && local.multi_runner_config_v1.ec2.matcherConfig.priority == 5 - && local.multi_runner_config_v1.ec2.matcherConfig.enableDynamicLabels - && tolist(local.multi_runner_config_v1.ec2.matcherConfig.awsDynamicLabelsPolicy.blocked_keys) == tolist(["instance-type"]) + local.multi_runner_config.ec2.tags.Scope == "entry" + && local.multi_runner_config.ec2.runner.tags.Scope == "runner" + && local.multi_runner_config.ec2.lambda.tags.Scope == "lambda" + && local.multi_runner_config.ec2.orchestration_provider.webhook.queue.tags.Scope == "queue" + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.up.tags.Scope == "scale-up" + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.scale.down.tags.Scope == "scale-down" + && local.multi_runner_config.ec2.orchestration_provider.webhook.lambda.pool.tags.Scope == "pool" + && local.multi_runner_config.ec2.orchestration_provider.webhook.job_retry.tags.Scope == "job-retry" + && local.multi_runner_config.ec2.ssm.tags.Scope == "ssm" + && local.multi_runner_config.ec2.ssm.parameters.tags.Scope == "ssm-parameters" + && local.multi_runner_config.ec2.ssm.housekeeper.tags.Scope == "ssm-housekeeper" + && local.multi_runner_config.ec2.observability.logs.tags.Scope == "logs" ) - error_message = "The v1 adapter must preserve matcher configuration." + error_message = "The v2 configuration must preserve every component tag scope." } + + assert { + condition = ( + local.multi_runner_config.ec2.runner.iam.additional_trust_policy_json == "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + && local.multi_runner_config.ec2.runner.iam.path == "/forge/" + && local.multi_runner_config.ec2.runner.iam.permissions_boundary == "arn:aws:iam::123456789012:policy/boundary" + ) + error_message = "The v2 configuration must preserve runner-configuration IAM ownership settings." + } + + assert { + condition = ( + local.multi_runner_config.ec2.lambda.runtime == "nodejs22.x" + && local.multi_runner_config.ec2.lambda.architecture == "x86_64" + && tolist(local.multi_runner_config.ec2.lambda.subnet_ids) == tolist(["subnet-lambda-override"]) + && tolist(local.multi_runner_config.ec2.lambda.security_group_ids) == tolist(["sg-lambda"]) + && local.multi_runner_config.ec2.lambda.role.path == "/lambda/" + && local.multi_runner_config.ec2.lambda.role.permissions_boundary == "arn:aws:iam::123456789012:policy/lambda-boundary" + ) + error_message = "The v2 configuration must preserve runner-configuration Lambda runtime, network, and role overrides." + } + + assert { + condition = ( + local.multi_runner_config.ec2.ssm.paths.root == "/custom-root" + && local.multi_runner_config.ec2.ssm.paths.tokens == "custom-tokens" + && local.multi_runner_config.ec2.ssm.paths.config == "custom-config" + && local.multi_runner_config.ec2.ssm.housekeeper.schedule_expression == "rate(12 hours)" + && local.multi_runner_config.ec2.ssm.housekeeper.state == "DISABLED" + && local.multi_runner_config.ec2.ssm.housekeeper.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/ssm-housekeeper.zip" + && local.multi_runner_config.ec2.ssm.housekeeper.lambda.memory_size == 384 + && local.multi_runner_config.ec2.ssm.housekeeper.lambda.timeout == 70 + && local.multi_runner_config.ec2.ssm.housekeeper.config.tokenPath == "custom-token-path" + && local.multi_runner_config.ec2.ssm.housekeeper.config.minimumDaysOld == 2 + && local.multi_runner_config.ec2.ssm.housekeeper.config.dryRun + ) + error_message = "The v2 configuration must preserve runner-configuration SSM paths and housekeeper overrides." + } + + assert { + condition = ( + local.multi_runner_config.ec2.observability.logs.level == "debug" + && local.multi_runner_config.ec2.observability.logs.retention_in_days == 14 + && local.multi_runner_config.ec2.observability.logs.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/22222222-2222-2222-2222-222222222222" + && local.multi_runner_config.ec2.observability.logs.class == "INFREQUENT_ACCESS" + && local.multi_runner_config.ec2.observability.tracing.mode == "Active" + && !local.multi_runner_config.ec2.observability.tracing.capture_http_requests + && !local.multi_runner_config.ec2.observability.tracing.capture_error + && !local.multi_runner_config.ec2.observability.metrics.enable + && local.multi_runner_config.ec2.observability.metrics.namespace == "Forge/Test" + && !local.multi_runner_config.ec2.observability.metrics.metric.enable_github_app_rate_limit + && local.multi_runner_config.ec2.observability.metrics.metric.enable_job_retry + ) + error_message = "The v2 configuration must preserve runner-configuration observability overrides." + } + + assert { + condition = ( + local.experimental_config.github.app.id == "12345" + && local.experimental_config.github.enterprise_server.url == null + && local.experimental_config.orchestration_provider.webhook.eventbridge.enable + && local.experimental_config.orchestration_provider.webhook.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" + && tolist(local.experimental_config.lambda.subnet_ids) == tolist(["subnet-test"]) + && length(local.experimental_config.lambda.security_group_ids) == 1 + && local.experimental_config.orchestration_provider.webhook.lambda.webhook.artifact.zip == "/private/tmp/forge-test-lambda-cache/webhook.zip" + && local.experimental_config.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/00000000-0000-0000-0000-000000000000" + && local.experimental_config.ssm.housekeeper.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/runners.zip" + && local.experimental_config.observability.logs.level == "info" + && local.experimental_config.observability.logs.retention_in_days == 3 + && local.experimental_config.compute_provider.aws.ec2.vpc_id == "vpc-test" + && tolist(local.experimental_config.compute_provider.aws.ec2.subnet_ids) == tolist(["subnet-default"]) + && local.experimental_config.compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip == "/private/tmp/forge-test-lambda-cache/runner-binaries-syncer.zip" + && local.experimental_config.multi_runner_config == local.multi_runner_config + ) + error_message = "Forge must populate the authoritative global experimental contract." + } + + assert { + condition = ( + local.experimental_config.tags == local.terraform_aws_github_runner_tags + && local.experimental_config.lambda.tags == local.terraform_aws_github_runner_tags + && local.experimental_config.ssm.parameters.tags == local.terraform_aws_github_runner_tags + ) + error_message = "Forge resource tags must be carried by the experimental global, Lambda, and Parameter Store tag maps." + } + + assert { + condition = ( + local.experimental_config.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn == aws_cloudwatch_log_group.webhook_api_gateway_access.arn + && local.experimental_config.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.format == local.webhook_api_gateway_access_log_format + ) + error_message = "Forge webhook API Gateway access-log settings must be carried by the experimental webhook contract." + } + } diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl index 297fd481..308f8b14 100644 --- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl @@ -47,43 +47,53 @@ run "platform_ec2_deployment_interface_contract" { "runner = object({", "os = string", "architecture = string", - "boot_time_in_minutes = optional(number, 5)", - "disable_default_labels = optional(bool, false)", - "extra_labels = optional(list(string), [])", - "group_name = optional(string, \"Default\")", - "name_prefix = optional(string, \"\")", - "run_as_root = optional(bool, false)", - "run_as = optional(string, \"ec2-user\")", - "maximum_count = number", - "ephemeral = optional(bool, false)", - "jit_config_enabled = optional(bool, null)", - "auto_update_disabled = optional(bool, false)", + "disable_default_labels = optional(bool, null)", + "extra_labels = optional(list(string), null)", + "group_name = optional(string, null)", + "name_prefix = optional(string, null)", + "run_as_root = optional(bool, null)", + "run_as = optional(string, null)", + "auto_update_disabled = optional(bool, null)", + "orchestration_provider = object({", + "webhook = optional(object({", + "boot_time_in_minutes = optional(number, null)", + "ephemeral = optional(bool, null)", + "jit_config_enabled = optional(bool, null)", + "maximum_count = number", "hooks = optional(object({", - "job_started = optional(string, \"\")", - "job_completed = optional(string, \"\")", + "job_started = optional(string, null)", + "job_completed = optional(string, null)", "iam = optional(object({", "role = optional(object({", - "managed_policy_arns = optional(map(string), {})", + "managed_policy_arns = optional(map(string), null)", "additional_trust_policy_json = optional(string, null)", "path = optional(string, null)", "permissions_boundary = optional(string, null)", "github = optional(object({", "organization_runners = optional(bool, false)", "lambda = optional(object({", + "runtime = optional(string, null)", + "architecture = optional(string, null)", + "security_group_ids = optional(list(string), null)", + "role = optional(object({", + "memory_size = optional(number, null)", + "timeout = optional(number, null)", "queue = optional(object({", - "delay_webhook_event = optional(number, 30)", - "job_queue_retention_in_seconds = optional(number, 86400)", + "delay_webhook_event = optional(number, null)", + "job_queue_retention_in_seconds = optional(number, null)", + "visibility_timeout_seconds = optional(number, null)", "event_source_mapping = optional(object({", "batch_size = optional(number, null)", "maximum_batching_window_in_seconds = optional(number, null)", "redrive_build_queue = optional(object({", - "enabled = bool", - "maxReceiveCount = number", - "scale_up = optional(object({", - "reserved_concurrent_executions = optional(number, 1)", + "enabled = optional(bool, null)", + "maxReceiveCount = optional(number, null)", + "scale = optional(object({", + "up = optional(object({", + "reserved_concurrent_executions = optional(number, null)", "job_queued_check_enabled = optional(bool, null)", - "scale_down = optional(object({", - "schedule_expression = optional(string, \"cron(*/5 * * * ? *)\")", + "down = optional(object({", + "schedule_expression = optional(string, null)", "minimum_running_time_in_minutes = optional(number, null)", "idle_config = optional(list(object({", "cron = string", @@ -95,7 +105,8 @@ run "platform_ec2_deployment_interface_contract" { "schedule_expression = string", "schedule_expression_timezone = optional(string)", "size = number", - "runner_owner = optional(string, null)", + "runner_owner = optional(string, null)", + "include_busy_runners = optional(bool, null)", "job_retry = optional(object({", "enabled = optional(bool, false)", "delay_in_seconds = optional(number, 300)", @@ -104,17 +115,37 @@ run "platform_ec2_deployment_interface_contract" { "memory_size = optional(number, 256)", "timeout = optional(number, 30)", "ssm = optional(object({", - "kms_key = optional(object({", + "paths = optional(object({", + "root = optional(string, null)", + "tokens = optional(string, null)", + "config = optional(string, null)", "parameters = optional(object({", "housekeeper = optional(object({", + "artifact = optional(object({", + "zip = optional(string, null)", + "object_version = optional(string, null)", + "state = optional(string, null)", + "tokenPath = optional(string, null)", + "minimumDaysOld = optional(number, null)", + "dryRun = optional(bool, null)", "observability = optional(object({", "logs = optional(object({", + "retention_in_days = optional(number, null)", + "class = optional(string, null)", + "tracing = optional(object({", + "capture_http_requests = optional(bool, null)", + "capture_error = optional(bool, null)", + "metrics = optional(object({", + "namespace = optional(string, null)", + "enable_github_app_rate_limit = optional(bool, null)", + "enable_job_retry = optional(bool, null)", "blocked_keys = optional(list(string), [])", "restricted_keys = optional(map(object({", "allowed = optional(list(string), [])", "denied = optional(list(string), [])", "max = optional(string, null)", "compute_provider = object({", + "aws = optional(object({", "ec2 = optional(object({", "metadata_options = optional(object({", "instance_metadata_tags = optional(string, \"enabled\")", @@ -153,12 +184,17 @@ run "platform_ec2_deployment_interface_contract" { "pre_install = optional(string, \"\")", "post_install = optional(string, \"\")", "debug_logging_enabled = optional(bool, false)", - "instance_allocation_strategy = optional(string, \"lowest-price\")", - "instance_max_spot_price = optional(string, null)", - "instance_target_capacity_type = optional(string, \"spot\")", - "instance_type_priorities = optional(map(number), null)", - "instance_types = list(string)", - "additional_security_group_ids = optional(list(string), [])", + "instance_allocation_strategy = optional(string, \"lowest-price\")", + "instance_max_spot_price = optional(string, null)", + "instance_target_capacity_type = optional(string, \"spot\")", + "instance_type_priorities = optional(map(number), null)", + "instance_types = list(string)", + "additional_security_group_ids = optional(list(string), null)", + "managed_security_group_enabled = optional(bool, null)", + "egress_rules = optional(list(object({", + "instance_profile_path = optional(string, null)", + "key_name = optional(string, null)", + "associate_public_ipv4_address = optional(bool, null)", "instance_profile = optional(object({", "name = string", "enable_on_demand_failover_for_errors = optional(list(string), [])", @@ -198,6 +234,9 @@ run "platform_ec2_deployment_interface_contract" { "enableDynamicLabels = optional(bool, false)", "awsDynamicLabelsPolicy = optional(object({", "for runner_config in values(var.runner_configs.runner_specs) :", + "for provider_config in values(runner_config.orchestration_provider) : provider_config", + "if provider_config != null", + "error_message = \"Each Forge runner configuration must select exactly one non-null orchestration provider.\"", "variable \"tenant_configs\"", "ecr_registries = list(string)", "tags = map(string)", @@ -206,27 +245,17 @@ run "platform_ec2_deployment_interface_contract" { "effective_runner_home_directories = {", "forge_runner_hook_job_started = {", "runner_hook_job_started = {", - "textencodebase64(runner_config.runner.hooks.job_started, \"UTF-16LE\")", - "base64encode(runner_config.runner.hooks.job_started)", - "length(runner_config.compute_provider.ec2[*]) == 1", - "length(runner_config.compute_provider.ec2.ami[*]) == 1", - "&& length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0,", + "effective_runner_hooks = {", + "textencodebase64(local.effective_runner_hooks[key].job_started, \"UTF-16LE\")", + "base64encode(local.effective_runner_hooks[key].job_started)", + "length(runner_config.compute_provider.aws.ec2[*]) == 1", + "length(runner_config.compute_provider.aws.ec2.ami[*]) == 1", + "&& length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0,", "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"", - "try(!runner_config.compute_provider.ec2.user_data.debug_logging_enabled, false)", - "error_message = \"Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active.\"", - "length(runner_config.compute_provider.ec2.instance_profile[*]) == 0", - "&& length(runner_config.runner.iam.role[*]) == 0,", - "error_message = \"Forge EC2 runner_specs do not support external runner.iam.role or compute_provider.ec2.instance_profile ownership while the upstream v1 adapter is active.\"", - "length(runner_config.runner.tags) == 0", - "&& length(runner_config.observability.logs.tags) == 0", - "error_message = \"Forge EC2 runner_specs only support compute_provider.ec2.tags while the upstream v1 adapter is active; all other v2 per-lane tag maps must remain empty.\"", - "runner_config.runner.iam.additional_trust_policy_json == null", - "&& runner_config.job_retry.lambda.reserved_concurrent_executions == 1", - "&& runner_config.ssm.kms_key == null", - "error_message = \"Forge EC2 runner_specs do not support per-lane IAM trust/path/boundary, non-default job-retry Lambda reserved concurrency, or per-lane SSM KMS keys while the upstream v1 adapter is active.\"", "active_ec2_subnet_ids = toset(flatten([", "legacy_runner_labels = {", - "matcher_index, labels in runner_config.matcherConfig.labelMatchers", + "runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers[0]", + "matcher_index, labels in runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers", "!contains(local.legacy_runner_labels[key], label)", "ec2_default_ami_filters = {", "windows = { name = [\"Windows_Server-2022-English-Full-ECS_Optimized-*\"] }", @@ -234,42 +263,45 @@ run "platform_ec2_deployment_interface_contract" { "osx = runner_config.runner.architecture == \"arm64\" ? { name = [\"amzn-ec2-macos-15.*-arm64\"] } : { name = [\"amzn-ec2-macos-15.*\"] }", "ec2_update_runner_ami_map = {", "local.ec2_default_ami_filters[key],", - "ami = runner_config.compute_provider.ec2.ami == null ? null : merge(", - "runner_config.compute_provider.ec2.ami.filter,", + "ami = runner_config.compute_provider.aws.ec2.ami == null ? null : merge(", + "runner_config.compute_provider.aws.ec2.ami.filter,", "ami_filter = local.ec2_compute_provider[key].ami.filter", "ami_owners = local.ec2_compute_provider[key].ami.owners", "runner_ami_map = local.ec2_update_runner_ami_map", "key => merge(", - "runner_config.compute_provider.ec2,", - "log_files = coalesce(runner_config.compute_provider.ec2.log_files, local.forge_ec2_log_files[key])", - "tags = merge(var.tenant_configs.tags, runner_config.compute_provider.ec2.tags)", - "multi_runner_config_v1 = {", - "runner_os = runner_config.runner.os", - "runner_metadata_options = local.ec2_compute_provider[key].metadata_options", - "runner_boot_time_in_minutes = runner_config.runner.boot_time_in_minutes", - "enable_organization_runners = runner_config.github.organization_runners", - "id_ssm_parameter_arn = try(local.ec2_compute_provider[key].ami.id_ssm_parameter.arn, null)", - "kms_key_arn = try(local.ec2_compute_provider[key].ami.kms_key.arn, null)", - "enable_cloudwatch_agent = local.ec2_compute_provider[key].cloudwatch_agent.enabled", - "enable_userdata = local.ec2_compute_provider[key].user_data.enabled", - "runner_additional_security_group_ids = local.ec2_compute_provider[key].additional_security_group_ids", - "runner_log_files = local.ec2_compute_provider[key].log_files", - "runner_ec2_tags = local.ec2_compute_provider[key].tags", + "runner_config.compute_provider.aws.ec2,", + "log_files = coalesce(runner_config.compute_provider.aws.ec2.log_files, local.forge_ec2_log_files[key])", + "tags = merge(var.tenant_configs.tags, runner_config.compute_provider.aws.ec2.tags)", + "multi_runner_config = {", + "key => merge(runner_config, {", + "runner = merge(runner_config.runner, {", + "iam = merge(runner_config.runner.iam, {", + "managed_policy_arns = merge(", + "\"forge-config-$${policy_index}\" => policy_arn", + "forge_ec2_tags = aws_iam_policy.ec2_tags.arn", + "forge_runner_hooks_ssm_read = aws_iam_policy.runner_hooks_ssm_read.arn", + "ec2 = local.ec2_compute_provider[key]", "runner_home = local.effective_runner_home_directories[key]", - "delay_webhook_event = runner_config.queue.delay_webhook_event", - "scale_up_reserved_concurrent_executions = runner_config.scale_up.reserved_concurrent_executions", - "idle_config = runner_config.scale_down.idle_config", - "pool_config = runner_config.pool.config", - "enable = runner_config.job_retry.enabled", - "lambda_memory_size = runner_config.job_retry.lambda.memory_size", - "runner_hook_job_started = local.runner_hook_job_started[key]", - "runner_iam_role_managed_policy_arns = concat(", - "for policy_name in sort(keys(runner_config.runner.iam.managed_policy_arns)) :", - "matcherConfig = runner_config.matcherConfig", - "redrive_build_queue = runner_config.queue.redrive_build_queue", - "multi_runner_config = local.multi_runner_config_v1", + "job_started = local.runner_hook_job_started[key]", + "runner_ssm_paths = {", + "runner_ami_ssm_parameter_names = {", + "root = \"$${trimsuffix(coalesce(runner_config.ssm.paths.root, \"/github-action-runners/$${var.runner_configs.prefix}\"), \"/\")}/$${key}\"", + "multi_runner_config = {}", + "experimental = local.experimental_config", + "experimental_config = {", + "app = var.runner_configs.github_app", + "enterprise_server = {", + "orchestration_provider = {", + "url = try(trimspace(var.runner_configs.ghes_url), \"\") == \"\" ? null : var.runner_configs.ghes_url", + "artifact = {", + "kms_key_id = aws_kms_key.github.arn", + "level = lower(var.runner_configs.log_level)", + "retention_in_days = tonumber(var.runner_configs.logging_retention_in_days)", + "runner_binaries = {", + "multi_runner_config = local.multi_runner_config", "output \"ec2_runners_arn_map\"", - "for runner_key, runner in module.runners.runners_map : runner_key => runner.role_runner[0].arn", + "for runner_key, runner in module.runners.runners_map_v2 : runner_key => coalesce(", + "try(local.ec2_runner_configs[runner_key].runner.iam.role.arn, null)", "output \"ec2_runners_ami_name_map\"", "data.aws_ami.runner_ami[runner_key].name", "output \"ec2_runners_labels_map\"", @@ -311,7 +343,7 @@ run "platform_ec2_deployment_interface_contract" { condition = ( output.expected_input_variable_count == 4 && output.expected_output_value_count == 6 - && output.expected_interface_literal_count == 257 + && output.expected_interface_literal_count == 289 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl index 30b83268..1900c52e 100644 --- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl +++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl @@ -11,13 +11,35 @@ run "platform_ec2_deployment_contract" { "module \"ec2_update_runner_ssm_ami\"", "module \"ec2_update_runner_tags\"", "module \"runners\"", - "source = \"git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=v7.10.1\"", - "multi_runner_config = local.multi_runner_config_v1", + "source = \"git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=experimental-multi-runner-config-v2-20260805\"", + "multi_runner_config = {}", + "experimental = local.experimental_config", + "experimental_config = {", + "app = var.runner_configs.github_app", + "enterprise_server = {", + "orchestration_provider = {", + "runner_config.compute_provider.aws.ec2,", + "ec2 = local.ec2_compute_provider[key]", + "boot_time_in_minutes = optional(number, null)", + "ephemeral = optional(bool, null)", + "jit_config_enabled = optional(bool, null)", + "maximum_count = number", + "scale = optional(object({", + "up = optional(object({", + "down = optional(object({", + "runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers[0]", + "matcher_index, labels in runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers", + "kms_key_id = aws_kms_key.github.arn", + "multi_runner_config = local.multi_runner_config", "resource \"aws_cloudwatch_log_group\" \"webhook_api_gateway_access\"", "resource \"aws_kms_key\" \"github\"", "resource \"aws_kms_alias\" \"github\"", "resource \"aws_ssm_parameter\" \"hook_job_started\"", "resource \"aws_ssm_parameter\" \"hook_job_completed\"", + "runner_ssm_paths = {", + "root = \"$${trimsuffix(coalesce(runner_config.ssm.paths.root, \"/github-action-runners/$${var.runner_configs.prefix}\"), \"/\")}/$${key}\"", + "runner_ami_ssm_parameter_names = {", + "key => \"$${paths.root}/$${paths.config}/ami_id\"", "resource \"aws_iam_policy\" \"runner_hooks_ssm_read\"", "resource \"aws_iam_policy\" \"ec2_tags\"", "resource \"aws_security_group\" \"gh_runner_lambda_egress\"", diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf index e0044ac7..46d461ac 100644 --- a/modules/platform/ec2_deployment/variables.tf +++ b/modules/platform/ec2_deployment/variables.tf @@ -22,306 +22,345 @@ variable "runner_configs" { runner = object({ os = string architecture = string - boot_time_in_minutes = optional(number, 5) - disable_default_labels = optional(bool, false) - extra_labels = optional(list(string), []) - group_name = optional(string, "Default") - name_prefix = optional(string, "") - run_as_root = optional(bool, false) - run_as = optional(string, "ec2-user") - maximum_count = number - ephemeral = optional(bool, false) - jit_config_enabled = optional(bool, null) - auto_update_disabled = optional(bool, false) + disable_default_labels = optional(bool, null) + extra_labels = optional(list(string), null) + group_name = optional(string, null) + name_prefix = optional(string, null) + run_as_root = optional(bool, null) + run_as = optional(string, null) + auto_update_disabled = optional(bool, null) tags = optional(map(string), {}) hooks = optional(object({ - job_started = optional(string, "") - job_completed = optional(string, "") + job_started = optional(string, null) + job_completed = optional(string, null) }), {}) iam = optional(object({ role = optional(object({ arn = string }), null) - managed_policy_arns = optional(map(string), {}) + managed_policy_arns = optional(map(string), null) additional_trust_policy_json = optional(string, null) path = optional(string, null) permissions_boundary = optional(string, null) }), {}) }) - github = optional(object({ - organization_runners = optional(bool, false) - }), {}) - lambda = optional(object({ - tags = optional(map(string), {}) - }), {}) - - queue = optional(object({ - delay_webhook_event = optional(number, 30) - job_queue_retention_in_seconds = optional(number, 86400) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) + runtime = optional(string, null) + architecture = optional(string, null) + subnet_ids = optional(list(string), null) + security_group_ids = optional(list(string), null) + tags = optional(map(string), {}) + role = optional(object({ + path = optional(string, null) + permissions_boundary = optional(string, null) }), {}) - redrive_build_queue = optional(object({ - enabled = bool - maxReceiveCount = number - }), { - enabled = false - maxReceiveCount = null - }) - tags = optional(map(string), {}) }), {}) - scale_up = optional(object({ - reserved_concurrent_executions = optional(number, 1) - job_queued_check_enabled = optional(bool, null) - tags = optional(map(string), {}) - }), {}) + orchestration_provider = object({ + webhook = optional(object({ + runner = object({ + boot_time_in_minutes = optional(number, null) + ephemeral = optional(bool, null) + jit_config_enabled = optional(bool, null) + maximum_count = number + }) - scale_down = optional(object({ - schedule_expression = optional(string, "cron(*/5 * * * ? *)") - minimum_running_time_in_minutes = optional(number, null) - tags = optional(map(string), {}) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), []) - }), {}) + github = optional(object({ + organization_runners = optional(bool, false) + }), {}) - pool = optional(object({ - config = optional(list(object({ - schedule_expression = string - schedule_expression_timezone = optional(string) - size = number - })), []) - runner_owner = optional(string, null) - tags = optional(map(string), {}) - }), {}) + matcherConfig = object({ + labelMatchers = list(list(string)) + exactMatch = optional(bool, false) + bidirectionalLabelMatch = optional(bool, false) + priority = optional(number, 999) + enableDynamicLabels = optional(bool, false) + awsDynamicLabelsPolicy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) + }) - job_retry = optional(object({ - enabled = optional(bool, false) - delay_in_seconds = optional(number, 300) - delay_backoff = optional(number, 2) - max_attempts = optional(number, 1) - tags = optional(map(string), {}) - lambda = optional(object({ - memory_size = optional(number, 256) - reserved_concurrent_executions = optional(number, 1) - timeout = optional(number, 30) - }), {}) - }), {}) + queue = optional(object({ + delay_webhook_event = optional(number, null) + job_queue_retention_in_seconds = optional(number, null) + visibility_timeout_seconds = optional(number, null) + redrive_build_queue = optional(object({ + enabled = optional(bool, null) + maxReceiveCount = optional(number, null) + }), null) + tags = optional(map(string), {}) + }), {}) + + lambda = optional(object({ + scale = optional(object({ + up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) + }), {}) + }), {}) + pool = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + config = optional(list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })), null) + include_busy_runners = optional(bool, null) + runner_owner = optional(string, null) + tags = optional(map(string), {}) + }), {}) + }), {}) + + job_retry = optional(object({ + enabled = optional(bool, false) + delay_in_seconds = optional(number, 300) + delay_backoff = optional(number, 2) + max_attempts = optional(number, 1) + tags = optional(map(string), {}) + lambda = optional(object({ + memory_size = optional(number, 256) + reserved_concurrent_executions = optional(number, 1) + timeout = optional(number, 30) + }), {}) + }), {}) + }), null) + }) ssm = optional(object({ + paths = optional(object({ + root = optional(string, null) + tokens = optional(string, null) + config = optional(string, null) + }), {}) tags = optional(map(string), {}) - kms_key = optional(object({ - arn = string - }), null) parameters = optional(object({ tags = optional(map(string), {}) }), {}) housekeeper = optional(object({ - tags = optional(map(string), {}) + schedule_expression = optional(string, null) + state = optional(string, null) + tags = optional(map(string), {}) + lambda = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + memory_size = optional(number, null) + timeout = optional(number, null) + }), {}) + config = optional(object({ + tokenPath = optional(string, null) + minimumDaysOld = optional(number, null) + dryRun = optional(bool, null) + }), {}) }), {}) }), {}) observability = optional(object({ logs = optional(object({ - tags = optional(map(string), {}) + level = optional(string, null) + retention_in_days = optional(number, null) + kms_key_id = optional(string, null) + class = optional(string, null) + tags = optional(map(string), {}) + }), {}) + tracing = optional(object({ + mode = optional(string, null) + capture_http_requests = optional(bool, null) + capture_error = optional(bool, null) + }), {}) + metrics = optional(object({ + enable = optional(bool, null) + namespace = optional(string, null) + metric = optional(object({ + enable_github_app_rate_limit = optional(bool, null) + enable_job_retry = optional(bool, null) + }), {}) }), {}) }), {}) compute_provider = object({ - ec2 = optional(object({ - metadata_options = optional(object({ - instance_metadata_tags = optional(string, "enabled") - http_endpoint = optional(string, "enabled") - http_tokens = optional(string, "required") - http_put_response_hop_limit = optional(number, 1) - }), {}) - ami = optional(object({ - filter = optional(map(list(string)), { state = ["available"] }) - owners = optional(list(string), ["amazon"]) - id_ssm_parameter = optional(object({ - arn = string + aws = optional(object({ + ec2 = optional(object({ + metadata_options = optional(object({ + instance_metadata_tags = optional(string, "enabled") + http_endpoint = optional(string, "enabled") + http_tokens = optional(string, "required") + http_put_response_hop_limit = optional(number, 1) + }), {}) + ami = optional(object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter = optional(object({ + arn = string + }), null) + kms_key = optional(object({ + arn = string + }), null) }), null) - kms_key = optional(object({ - arn = string + block_device_mappings = optional(list(object({ + delete_on_termination = optional(bool, true) + device_name = optional(string, "/dev/xvda") + encrypted = optional(bool, true) + iops = optional(number) + kms_key_id = optional(string) + snapshot_id = optional(string) + throughput = optional(number) + volume_initialization_rate = optional(number) + volume_size = number + volume_type = optional(string, "gp3") + })), [{ + volume_size = 30 + }]) + create_service_linked_role_spot = optional(bool, false) + credit_specification = optional(string, null) + ebs_optimized = optional(bool, false) + cloudwatch_agent = optional(object({ + enabled = optional(bool, true) + config = optional(string, null) + }), {}) + binaries_syncer = optional(object({ + enabled = optional(bool, null) + }), {}) + detailed_monitoring_enabled = optional(bool, false) + ssm_enabled = optional(bool, false) + user_data = optional(object({ + enabled = optional(bool, true) + template = optional(string, null) + content = optional(string, null) + pre_install = optional(string, "") + post_install = optional(string, "") + debug_logging_enabled = optional(bool, false) + }), {}) + instance_allocation_strategy = optional(string, "lowest-price") + instance_max_spot_price = optional(string, null) + instance_target_capacity_type = optional(string, "spot") + instance_type_priorities = optional(map(number), null) + instance_types = list(string) + additional_security_group_ids = optional(list(string), null) + managed_security_group_enabled = optional(bool, null) + egress_rules = optional(list(object({ + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + prefix_list_ids = list(string) + from_port = number + protocol = string + security_groups = list(string) + self = bool + to_port = number + description = string + })), null) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, null) + instance_profile = optional(object({ + name = string }), null) + enable_on_demand_failover_for_errors = optional(list(string), []) + scale_errors = optional(list(string), [ + "UnfulfillableCapacity", + "MaxSpotInstanceCountExceeded", + "TargetCapacityLimitExceededException", + "RequestLimitExceeded", + "ResourceLimitExceeded", + "MaxSpotInstanceCountExceeded", + "MaxSpotFleetRequestCountExceeded", + "InsufficientInstanceCapacity", + "InsufficientCapacityOnHost", + ]) + subnet_ids = optional(list(string), null) + vpc_id = optional(string, null) + cpu_options = optional(object({ + core_count = optional(number) + threads_per_core = optional(number) + amd_sev_snp = optional(string) + nested_virtualization = optional(string) + }), null) + placement = optional(object({ + affinity = optional(string) + availability_zone = optional(string) + group_id = optional(string) + group_name = optional(string) + host_id = optional(string) + host_resource_group_arn = optional(string) + spread_domain = optional(string) + tenancy = optional(string) + partition_number = optional(number) + }), null) + license_specifications = optional(list(object({ + license_configuration_arn = string + })), []) + use_dedicated_host = optional(bool, false) + log_files = optional(list(object({ + log_group_name = string + prefix_log_group = bool + file_path = string + log_stream_name = string + log_class = optional(string, "STANDARD") + })), null) + tags = optional(map(string), {}) }), null) - block_device_mappings = optional(list(object({ - delete_on_termination = optional(bool, true) - device_name = optional(string, "/dev/xvda") - encrypted = optional(bool, true) - iops = optional(number) - kms_key_id = optional(string) - snapshot_id = optional(string) - throughput = optional(number) - volume_initialization_rate = optional(number) - volume_size = number - volume_type = optional(string, "gp3") - })), [{ - volume_size = 30 - }]) - create_service_linked_role_spot = optional(bool, false) - credit_specification = optional(string, null) - ebs_optimized = optional(bool, false) - cloudwatch_agent = optional(object({ - enabled = optional(bool, true) - config = optional(string, null) - }), {}) - binaries_syncer = optional(object({ - enabled = optional(bool, true) - }), {}) - detailed_monitoring_enabled = optional(bool, false) - ssm_enabled = optional(bool, false) - user_data = optional(object({ - enabled = optional(bool, true) - template = optional(string, null) - content = optional(string, null) - pre_install = optional(string, "") - post_install = optional(string, "") - debug_logging_enabled = optional(bool, false) - }), {}) - instance_allocation_strategy = optional(string, "lowest-price") - instance_max_spot_price = optional(string, null) - instance_target_capacity_type = optional(string, "spot") - instance_type_priorities = optional(map(number), null) - instance_types = list(string) - additional_security_group_ids = optional(list(string), []) - instance_profile = optional(object({ - name = string - }), null) - enable_on_demand_failover_for_errors = optional(list(string), []) - scale_errors = optional(list(string), [ - "UnfulfillableCapacity", - "MaxSpotInstanceCountExceeded", - "TargetCapacityLimitExceededException", - "RequestLimitExceeded", - "ResourceLimitExceeded", - "MaxSpotInstanceCountExceeded", - "MaxSpotFleetRequestCountExceeded", - "InsufficientInstanceCapacity", - "InsufficientCapacityOnHost", - ]) - subnet_ids = optional(list(string), null) - vpc_id = optional(string, null) - cpu_options = optional(object({ - core_count = optional(number) - threads_per_core = optional(number) - amd_sev_snp = optional(string) - nested_virtualization = optional(string) - }), null) - placement = optional(object({ - affinity = optional(string) - availability_zone = optional(string) - group_id = optional(string) - group_name = optional(string) - host_id = optional(string) - host_resource_group_arn = optional(string) - spread_domain = optional(string) - tenancy = optional(string) - partition_number = optional(number) - }), null) - license_specifications = optional(list(object({ - license_configuration_arn = string - })), []) - use_dedicated_host = optional(bool, false) - log_files = optional(list(object({ - log_group_name = string - prefix_log_group = bool - file_path = string - log_stream_name = string - log_class = optional(string, "STANDARD") - })), null) - tags = optional(map(string), {}) - }), null) + }), {}) }) - matcherConfig = object({ - labelMatchers = list(list(string)) - exactMatch = optional(bool, false) - bidirectionalLabelMatch = optional(bool, false) - priority = optional(number, 999) - enableDynamicLabels = optional(bool, false) - awsDynamicLabelsPolicy = optional(object({ - blocked_keys = optional(list(string), []) - restricted_keys = optional(map(object({ - allowed = optional(list(string), []) - denied = optional(list(string), []) - max = optional(string, null) - })), {}) - }), null) - }) })) }) validation { condition = alltrue([ for runner_config in values(var.runner_configs.runner_specs) : - try( - length(runner_config.compute_provider.ec2[*]) == 1 - && length(runner_config.compute_provider.ec2.ami[*]) == 1 - && length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, - false, - ) + length([ + for provider_config in values(runner_config.orchestration_provider) : provider_config + if provider_config != null + ]) == 1 ]) - error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." - } - - validation { - condition = alltrue([ - for runner_config in values(var.runner_configs.runner_specs) : - try(!runner_config.compute_provider.ec2.user_data.debug_logging_enabled, false) - ]) - error_message = "Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active." + error_message = "Each Forge runner configuration must select exactly one non-null orchestration provider." } validation { condition = alltrue([ for runner_config in values(var.runner_configs.runner_specs) : try( - length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 - && length(runner_config.runner.iam.role[*]) == 0, + length(runner_config.compute_provider.aws.ec2[*]) == 1 + && length(runner_config.compute_provider.aws.ec2.ami[*]) == 1 + && length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0, false, ) ]) - error_message = "Forge EC2 runner_specs do not support external runner.iam.role or compute_provider.ec2.instance_profile ownership while the upstream v1 adapter is active." - } - - validation { - condition = alltrue([ - for runner_config in values(var.runner_configs.runner_specs) : - length(runner_config.tags) == 0 - && length(runner_config.runner.tags) == 0 - && length(runner_config.lambda.tags) == 0 - && length(runner_config.queue.tags) == 0 - && length(runner_config.scale_up.tags) == 0 - && length(runner_config.scale_down.tags) == 0 - && length(runner_config.pool.tags) == 0 - && length(runner_config.job_retry.tags) == 0 - && length(runner_config.ssm.tags) == 0 - && length(runner_config.ssm.parameters.tags) == 0 - && length(runner_config.ssm.housekeeper.tags) == 0 - && length(runner_config.observability.logs.tags) == 0 - ]) - error_message = "Forge EC2 runner_specs only support compute_provider.ec2.tags while the upstream v1 adapter is active; all other v2 per-lane tag maps must remain empty." + error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." } - validation { - condition = alltrue([ - for runner_config in values(var.runner_configs.runner_specs) : - runner_config.runner.iam.additional_trust_policy_json == null - && runner_config.runner.iam.path == null - && runner_config.runner.iam.permissions_boundary == null - && runner_config.job_retry.lambda.reserved_concurrent_executions == 1 - && runner_config.ssm.kms_key == null - ]) - error_message = "Forge EC2 runner_specs do not support per-lane IAM trust/path/boundary, non-default job-retry Lambda reserved concurrency, or per-lane SSM KMS keys while the upstream v1 adapter is active." - } } variable "network_configs" { diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md index 70ec004c..c13bff69 100644 --- a/modules/platform/forge_runners/README.md +++ b/modules/platform/forge_runners/README.md @@ -18,12 +18,26 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived - This is the best starting point when onboarding or debugging a tenant because it shows how the platform pieces compose. - The tenant `deployment_config` defines the GitHub App, runner group, repository selection, and IAM bridge. -- EC2 and ARC lanes can be enabled for the same tenant; workflows choose by labels. +- EC2 and ARC runner configurations can be enabled for the same tenant; workflows choose by labels. - `ec2_deployment_specs.runner_specs` uses the full nested v2 shape; every entry - must configure `runner`, `matcherConfig`, and `compute_provider.ec2`. -- `ec2_deployment` translates that public shape to the released upstream v1 - `multi_runner_config`; this phase does not enable the experimental v2 runtime - or change its Terraform module/resource addresses. + must configure `runner`, `orchestration_provider.webhook`, and `compute_provider.aws.ec2`. +- `ec2_deployment` enriches that public shape with Forge-managed settings and + passes it through upstream's `experimental.multi_runner_config`; shared + GitHub API, common Lambda substrate, SSM KMS and housekeeper artifact, + observability, and EC2 network settings are populated in the experimental + global blocks. +- Webhook runner lifecycle, capacity, startup timing, GitHub scope, matching, + queue, scale-up, scale-down, pool, and retry settings are nested below + `orchestration_provider.webhook`; the common per-configuration `lambda` block remains + orchestration-provider-neutral. +- The shared runner-control archive is selected globally at + `orchestration_provider.webhook.lambda.artifact`; per-runner webhook Lambda blocks do + not select deployment artifacts. +- The v2 runner graph changes internal Terraform addresses. Upstream does not + include an automatic state move from the prior experimental + `module.runner_stacks` address to `module.runner_configs`, or from the stable + v1 graph. Existing deployments require an explicit state migration and + separate exact-ref Lambda artifact validation. - Changing GitHub App or runner-group settings can affect job routing immediately. ## Deployment version inventory @@ -67,10 +81,10 @@ tag with the expected ref in `release_versions.yml`. | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.58.0 | -| [null](#provider\_null) | 3.3.0 | +| [aws](#provider\_aws) | 6.60.0 | +| [null](#provider\_null) | 3.3.1 | | [random](#provider\_random) | 3.9.0 | -| [time](#provider\_time) | 0.14.0 | +| [time](#provider\_time) | 0.14.1 | ## Modules @@ -119,7 +133,7 @@ tag with the expected ref in `release_versions.yml`. | [aws\_region](#input\_aws\_region) | AWS region where Forge runners and supporting infrastructure are deployed. | `string` | n/a | yes | | [default\_tags](#input\_default\_tags) | A map of tags to apply to resources. | `map(string)` | n/a | yes | | [deployment\_config](#input\_deployment\_config) | High-level deployment configuration for a Forge runner installation.

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

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

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

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

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

runner\_specs[*] object fields:
- runner : Provider-neutral OS, architecture, labels, registration,
hooks, capacity, and IAM-policy configuration.
- github : Organization-versus-repository runner registration.
- queue : Webhook delay, retention, Lambda batching, and DLQ
redrive configuration.
- scale\_up : Scale-up concurrency and queued-job checks.
- scale\_down : Scale-down schedule, minimum runtime, and idle runners.
- pool : Scheduled warm-pool sizes and runner owner.
- job\_retry : Retry timing, attempts, and Lambda sizing.
- matcherConfig : Static and dynamic GitHub label matching.
- compute\_provider: Nested v2-compatible EC2 provider configuration.
- tags/lambda/ssm/observability: Included for v2 contract compatibility.
Tag scopes and per-lane SSM KMS settings that cannot be
represented by v1 must retain their defaults.

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

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

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

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

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

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

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

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

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

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

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

observability = optional(object({
logs = optional(object({
tags = optional(map(string), {})
}), {})
}), {})

compute_provider = object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, true)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), [])
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
})

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

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

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

compute\_provider.aws.ec2 fields:
- ami : Upstream-compatible EC2 AMI configuration.
Forge requires a module-managed AMI block; null and
external AMI parameter ownership are unsupported.
- metadata\_options: EC2 instance metadata service configuration.
- block\_device\_mappings: EBS mappings for runner instances.
- cloudwatch\_agent/binaries\_syncer/user\_data: Runner bootstrap configuration.
- instance\_types and allocation fields: EC2 Fleet capacity configuration.
- vpc\_id/subnet\_ids/security-group fields: Per-configuration networking.
- cpu\_options/placement/license\_specifications: EC2 launch-template options.
- instance\_profile: Optional externally managed instance profile. It requires
an externally managed runner IAM role whose owner also
supplies Forge's EC2-tag and hook-SSM permissions.
- log\_files/tags : EC2 logging and resource tags. |
object({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
tags = optional(map(string), {})

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

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

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

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

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

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

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

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

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

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

compute_provider = object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
}), {})
})

}))
})
| n/a | yes | | [github\_webhook\_relay](#input\_github\_webhook\_relay) | Configuration for the (optional) webhook relay source module.
If enabled=true we provision the API Gateway + source EventBridge forwarding rule.
destination\_event\_bus\_name must already exist or be created in the destination account (or via the destination submodule run there). |
object({
enabled = bool
destination_account_id = optional(string)
destination_event_bus_name = optional(string)
destination_region = optional(string)
destination_reader_role_arn = optional(string)
})
|
{
"destination_account_id": "",
"destination_event_bus_name": "",
"destination_reader_role_arn": "",
"destination_region": "",
"enabled": false
}
| no | | [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | n/a | yes | | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Logging retention period in days. | `string` | n/a | yes | diff --git a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl index 6dca56d8..1ba66eea 100644 --- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl +++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl @@ -143,43 +143,53 @@ run "platform_forge_runners_interface_contract" { "runner = object({", "os = string", "architecture = string", - "boot_time_in_minutes = optional(number, 5)", - "disable_default_labels = optional(bool, false)", - "extra_labels = optional(list(string), [])", - "group_name = optional(string, \"Default\")", - "name_prefix = optional(string, \"\")", - "run_as_root = optional(bool, false)", - "run_as = optional(string, \"ec2-user\")", - "maximum_count = number", - "ephemeral = optional(bool, false)", - "jit_config_enabled = optional(bool, null)", - "auto_update_disabled = optional(bool, false)", + "disable_default_labels = optional(bool, null)", + "extra_labels = optional(list(string), null)", + "group_name = optional(string, null)", + "name_prefix = optional(string, null)", + "run_as_root = optional(bool, null)", + "run_as = optional(string, null)", + "auto_update_disabled = optional(bool, null)", + "orchestration_provider = object({", + "webhook = optional(object({", + "boot_time_in_minutes = optional(number, null)", + "ephemeral = optional(bool, null)", + "jit_config_enabled = optional(bool, null)", + "maximum_count = number", "hooks = optional(object({", - "job_started = optional(string, \"\")", - "job_completed = optional(string, \"\")", + "job_started = optional(string, null)", + "job_completed = optional(string, null)", "iam = optional(object({", "role = optional(object({", - "managed_policy_arns = optional(map(string), {})", + "managed_policy_arns = optional(map(string), null)", "additional_trust_policy_json = optional(string, null)", "path = optional(string, null)", "permissions_boundary = optional(string, null)", "github = optional(object({", "organization_runners = optional(bool, false)", "lambda = optional(object({", + "runtime = optional(string, null)", + "architecture = optional(string, null)", + "security_group_ids = optional(list(string), null)", + "role = optional(object({", + "memory_size = optional(number, null)", + "timeout = optional(number, null)", "queue = optional(object({", - "delay_webhook_event = optional(number, 30)", - "job_queue_retention_in_seconds = optional(number, 86400)", + "delay_webhook_event = optional(number, null)", + "job_queue_retention_in_seconds = optional(number, null)", + "visibility_timeout_seconds = optional(number, null)", "event_source_mapping = optional(object({", "batch_size = optional(number, null)", "maximum_batching_window_in_seconds = optional(number, null)", "redrive_build_queue = optional(object({", - "enabled = bool", - "maxReceiveCount = number", - "scale_up = optional(object({", - "reserved_concurrent_executions = optional(number, 1)", + "enabled = optional(bool, null)", + "maxReceiveCount = optional(number, null)", + "scale = optional(object({", + "up = optional(object({", + "reserved_concurrent_executions = optional(number, null)", "job_queued_check_enabled = optional(bool, null)", - "scale_down = optional(object({", - "schedule_expression = optional(string, \"cron(*/5 * * * ? *)\")", + "down = optional(object({", + "schedule_expression = optional(string, null)", "minimum_running_time_in_minutes = optional(number, null)", "idle_config = optional(list(object({", "cron = string", @@ -191,7 +201,8 @@ run "platform_forge_runners_interface_contract" { "schedule_expression = string", "schedule_expression_timezone = optional(string)", "size = number", - "runner_owner = optional(string, null)", + "runner_owner = optional(string, null)", + "include_busy_runners = optional(bool, null)", "job_retry = optional(object({", "enabled = optional(bool, false)", "delay_in_seconds = optional(number, 300)", @@ -200,17 +211,37 @@ run "platform_forge_runners_interface_contract" { "memory_size = optional(number, 256)", "timeout = optional(number, 30)", "ssm = optional(object({", - "kms_key = optional(object({", + "paths = optional(object({", + "root = optional(string, null)", + "tokens = optional(string, null)", + "config = optional(string, null)", "parameters = optional(object({", "housekeeper = optional(object({", + "artifact = optional(object({", + "zip = optional(string, null)", + "object_version = optional(string, null)", + "state = optional(string, null)", + "tokenPath = optional(string, null)", + "minimumDaysOld = optional(number, null)", + "dryRun = optional(bool, null)", "observability = optional(object({", "logs = optional(object({", + "retention_in_days = optional(number, null)", + "class = optional(string, null)", + "tracing = optional(object({", + "capture_http_requests = optional(bool, null)", + "capture_error = optional(bool, null)", + "metrics = optional(object({", + "namespace = optional(string, null)", + "enable_github_app_rate_limit = optional(bool, null)", + "enable_job_retry = optional(bool, null)", "blocked_keys = optional(list(string), [])", "restricted_keys = optional(map(object({", "allowed = optional(list(string), [])", "denied = optional(list(string), [])", "max = optional(string, null)", "compute_provider = object({", + "aws = optional(object({", "ec2 = optional(object({", "metadata_options = optional(object({", "instance_metadata_tags = optional(string, \"enabled\")", @@ -249,12 +280,17 @@ run "platform_forge_runners_interface_contract" { "pre_install = optional(string, \"\")", "post_install = optional(string, \"\")", "debug_logging_enabled = optional(bool, false)", - "instance_allocation_strategy = optional(string, \"lowest-price\")", - "instance_max_spot_price = optional(string, null)", - "instance_target_capacity_type = optional(string, \"spot\")", - "instance_type_priorities = optional(map(number), null)", - "instance_types = list(string)", - "additional_security_group_ids = optional(list(string), [])", + "instance_allocation_strategy = optional(string, \"lowest-price\")", + "instance_max_spot_price = optional(string, null)", + "instance_target_capacity_type = optional(string, \"spot\")", + "instance_type_priorities = optional(map(number), null)", + "instance_types = list(string)", + "additional_security_group_ids = optional(list(string), null)", + "managed_security_group_enabled = optional(bool, null)", + "egress_rules = optional(list(object({", + "instance_profile_path = optional(string, null)", + "key_name = optional(string, null)", + "associate_public_ipv4_address = optional(bool, null)", "instance_profile = optional(object({", "name = string", "enable_on_demand_failover_for_errors = optional(list(string), [])", @@ -294,37 +330,38 @@ run "platform_forge_runners_interface_contract" { "enableDynamicLabels = optional(bool, false)", "awsDynamicLabelsPolicy = optional(object({", "for runner_config in values(var.ec2_deployment_specs.runner_specs) :", - "length(runner_config.compute_provider.ec2[*]) == 1", - "length(runner_config.compute_provider.ec2.ami[*]) == 1", - "&& length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0,", + "for provider_config in values(runner_config.orchestration_provider) : provider_config", + "if provider_config != null", + "error_message = \"Each Forge runner configuration must select exactly one non-null orchestration provider.\"", + "length(runner_config.compute_provider.aws.ec2[*]) == 1", + "length(runner_config.compute_provider.aws.ec2.ami[*]) == 1", + "&& length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0,", "error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"", - "try(!runner_config.compute_provider.ec2.user_data.debug_logging_enabled, false)", - "error_message = \"Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active.\"", - "length(runner_config.compute_provider.ec2.instance_profile[*]) == 0", - "&& length(runner_config.runner.iam.role[*]) == 0,", - "error_message = \"Forge EC2 runner_specs do not support external runner.iam.role or compute_provider.ec2.instance_profile ownership while the upstream v1 adapter is active.\"", - "length(runner_config.runner.tags) == 0", - "&& length(runner_config.observability.logs.tags) == 0", - "error_message = \"Forge EC2 runner_specs only support compute_provider.ec2.tags while the upstream v1 adapter is active; all other v2 per-lane tag maps must remain empty.\"", - "runner_config.runner.iam.additional_trust_policy_json == null", - "&& runner_config.job_retry.lambda.reserved_concurrent_executions == 1", - "&& runner_config.ssm.kms_key == null", - "error_message = \"Forge EC2 runner_specs do not support per-lane IAM trust/path/boundary, non-default job-retry Lambda reserved concurrency, or per-lane SSM KMS keys while the upstream v1 adapter is active.\"", "EC2 deployment configuration for GitHub Actions runners. The public runner", "- lambda_subnet_ids: Subnets where runner-related lambdas execute.", "These can be more permissive than the runner subnets.", "- subnet_ids : Default subnets for EC2 runners.", "- vpc_id : VPC that contains both runner and lambda subnets.", - "- runner_specs : Map of EC2 runner lanes.", - "- runner : Provider-neutral OS, architecture, labels, registration,", - "- queue : Webhook delay, retention, Lambda batching, and DLQ", - "- job_retry : Retry timing, attempts, and Lambda sizing.", - "- matcherConfig : Static and dynamic GitHub label matching.", - "- compute_provider: Nested v2-compatible EC2 provider configuration.", - "- tags/lambda/ssm/observability: Included for v2 contract compatibility.", - "compute_provider.ec2 fields:", + "- runner_specs : Map of EC2 runner configurations.", + "- runner : Provider-neutral OS, architecture, labels, bootstrap,", + "hooks, and IAM-policy configuration.", + "shape follows the nested experimental multi_runner_config EC2 contract and", + "is passed directly to the upstream provider-oriented runner stack.", + "- lambda : Provider-neutral per-configuration Lambda runtime,", + "network, role, and tag overrides.", + "- orchestration_provider : Exactly one demand-controller provider. The webhook", + "provider owns runner lifecycle, capacity, and startup", + "timing, GitHub scope, matching, queues, scaling, pools,", + "and job retry.", + "- ssm : Per-configuration paths, tags, parameter tags, and", + "housekeeper settings, including its Lambda artifact.", + "- observability : Per-configuration logging, tracing, and metrics overrides.", + "- compute_provider : Nested v2 EC2 provider configuration.", + "- tags : Per-configuration resource tags.", + "compute_provider.aws.ec2 fields:", "- ami : Upstream-compatible EC2 AMI configuration.", - "The v7.10.1 compatibility adapter also requires external runner IAM ownership,", + "- instance_profile: Optional externally managed instance profile. It requires", + "supplies Forge's EC2-tag and hook-SSM permissions.", "variable \"github_webhook_relay\"", "Configuration for the (optional) webhook relay source module.", "If enabled=true we provision the API Gateway + source EventBridge forwarding rule.", @@ -411,7 +448,7 @@ run "platform_forge_runners_interface_contract" { condition = ( output.expected_input_variable_count == 10 && output.expected_output_value_count == 5 - && output.expected_interface_literal_count == 352 + && output.expected_interface_literal_count == 389 ) error_message = "Interface contract counts must remain pinned for inputs, outputs, and source literals." } diff --git a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl index 996fecf7..a5797fa3 100644 --- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl +++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl @@ -19,12 +19,23 @@ run "platform_forge_runners_contract" { "resource \"random_id\" \"random\"", "runner_specs = var.ec2_deployment_specs.runner_specs", "runner = object({", + "orchestration_provider = object({", + "webhook = optional(object({", + "boot_time_in_minutes = optional(number, null)", + "ephemeral = optional(bool, null)", + "jit_config_enabled = optional(bool, null)", + "maximum_count = number", + "lambda = optional(object({", "queue = optional(object({", - "scale_up = optional(object({", - "scale_down = optional(object({", + "visibility_timeout_seconds = optional(number, null)", + "scale = optional(object({", + "up = optional(object({", + "down = optional(object({", "pool = optional(object({", "job_retry = optional(object({", + "artifact = optional(object({", "compute_provider = object({", + "aws = optional(object({", "ec2 = optional(object({", "matcherConfig = object({", "scale_errors = optional(list(string), [", diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf index c765dd8b..33a02af9 100644 --- a/modules/platform/forge_runners/variables.tf +++ b/modules/platform/forge_runners/variables.tf @@ -20,354 +20,386 @@ variable "ec2_deployment_specs" { runner = object({ os = string architecture = string - boot_time_in_minutes = optional(number, 5) - disable_default_labels = optional(bool, false) - extra_labels = optional(list(string), []) - group_name = optional(string, "Default") - name_prefix = optional(string, "") - run_as_root = optional(bool, false) - run_as = optional(string, "ec2-user") - maximum_count = number - ephemeral = optional(bool, false) - jit_config_enabled = optional(bool, null) - auto_update_disabled = optional(bool, false) + disable_default_labels = optional(bool, null) + extra_labels = optional(list(string), null) + group_name = optional(string, null) + name_prefix = optional(string, null) + run_as_root = optional(bool, null) + run_as = optional(string, null) + auto_update_disabled = optional(bool, null) tags = optional(map(string), {}) hooks = optional(object({ - job_started = optional(string, "") - job_completed = optional(string, "") + job_started = optional(string, null) + job_completed = optional(string, null) }), {}) iam = optional(object({ role = optional(object({ arn = string }), null) - managed_policy_arns = optional(map(string), {}) + managed_policy_arns = optional(map(string), null) additional_trust_policy_json = optional(string, null) path = optional(string, null) permissions_boundary = optional(string, null) }), {}) }) - github = optional(object({ - organization_runners = optional(bool, false) - }), {}) - lambda = optional(object({ - tags = optional(map(string), {}) - }), {}) - - queue = optional(object({ - delay_webhook_event = optional(number, 30) - job_queue_retention_in_seconds = optional(number, 86400) - event_source_mapping = optional(object({ - batch_size = optional(number, null) - maximum_batching_window_in_seconds = optional(number, null) + runtime = optional(string, null) + architecture = optional(string, null) + subnet_ids = optional(list(string), null) + security_group_ids = optional(list(string), null) + tags = optional(map(string), {}) + role = optional(object({ + path = optional(string, null) + permissions_boundary = optional(string, null) }), {}) - redrive_build_queue = optional(object({ - enabled = bool - maxReceiveCount = number - }), { - enabled = false - maxReceiveCount = null - }) - tags = optional(map(string), {}) }), {}) - scale_up = optional(object({ - reserved_concurrent_executions = optional(number, 1) - job_queued_check_enabled = optional(bool, null) - tags = optional(map(string), {}) - }), {}) + orchestration_provider = object({ + webhook = optional(object({ + runner = object({ + boot_time_in_minutes = optional(number, null) + ephemeral = optional(bool, null) + jit_config_enabled = optional(bool, null) + maximum_count = number + }) + + github = optional(object({ + organization_runners = optional(bool, false) + }), {}) - scale_down = optional(object({ - schedule_expression = optional(string, "cron(*/5 * * * ? *)") - minimum_running_time_in_minutes = optional(number, null) - tags = optional(map(string), {}) - idle_config = optional(list(object({ - cron = string - timeZone = string - idleCount = number - evictionStrategy = optional(string, "oldest_first") - })), []) - }), {}) + matcherConfig = object({ + labelMatchers = list(list(string)) + exactMatch = optional(bool, false) + bidirectionalLabelMatch = optional(bool, false) + priority = optional(number, 999) + enableDynamicLabels = optional(bool, false) + awsDynamicLabelsPolicy = optional(object({ + blocked_keys = optional(list(string), []) + restricted_keys = optional(map(object({ + allowed = optional(list(string), []) + denied = optional(list(string), []) + max = optional(string, null) + })), {}) + }), null) + }) + + queue = optional(object({ + delay_webhook_event = optional(number, null) + job_queue_retention_in_seconds = optional(number, null) + visibility_timeout_seconds = optional(number, null) + redrive_build_queue = optional(object({ + enabled = optional(bool, null) + maxReceiveCount = optional(number, null) + }), null) + tags = optional(map(string), {}) + }), {}) - pool = optional(object({ - config = optional(list(object({ - schedule_expression = string - schedule_expression_timezone = optional(string) - size = number - })), []) - runner_owner = optional(string, null) - tags = optional(map(string), {}) - }), {}) + lambda = optional(object({ + scale = optional(object({ + up = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + job_queued_check_enabled = optional(bool, null) + event_source_mapping = optional(object({ + batch_size = optional(number, null) + maximum_batching_window_in_seconds = optional(number, null) + }), {}) + tags = optional(map(string), {}) + }), {}) + down = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + schedule_expression = optional(string, null) + minimum_running_time_in_minutes = optional(number, null) + idle_config = optional(list(object({ + cron = string + timeZone = string + idleCount = number + evictionStrategy = optional(string, "oldest_first") + })), null) + tags = optional(map(string), {}) + }), {}) + }), {}) + pool = optional(object({ + memory_size = optional(number, null) + timeout = optional(number, null) + reserved_concurrent_executions = optional(number, null) + config = optional(list(object({ + schedule_expression = string + schedule_expression_timezone = optional(string) + size = number + })), null) + include_busy_runners = optional(bool, null) + runner_owner = optional(string, null) + tags = optional(map(string), {}) + }), {}) + }), {}) - job_retry = optional(object({ - enabled = optional(bool, false) - delay_in_seconds = optional(number, 300) - delay_backoff = optional(number, 2) - max_attempts = optional(number, 1) - tags = optional(map(string), {}) - lambda = optional(object({ - memory_size = optional(number, 256) - reserved_concurrent_executions = optional(number, 1) - timeout = optional(number, 30) - }), {}) - }), {}) + job_retry = optional(object({ + enabled = optional(bool, false) + delay_in_seconds = optional(number, 300) + delay_backoff = optional(number, 2) + max_attempts = optional(number, 1) + tags = optional(map(string), {}) + lambda = optional(object({ + memory_size = optional(number, 256) + reserved_concurrent_executions = optional(number, 1) + timeout = optional(number, 30) + }), {}) + }), {}) + }), null) + }) ssm = optional(object({ + paths = optional(object({ + root = optional(string, null) + tokens = optional(string, null) + config = optional(string, null) + }), {}) tags = optional(map(string), {}) - kms_key = optional(object({ - arn = string - }), null) parameters = optional(object({ tags = optional(map(string), {}) }), {}) housekeeper = optional(object({ - tags = optional(map(string), {}) + schedule_expression = optional(string, null) + state = optional(string, null) + tags = optional(map(string), {}) + lambda = optional(object({ + artifact = optional(object({ + zip = optional(string, null) + s3 = optional(object({ + key = string + object_version = optional(string, null) + }), null) + }), {}) + memory_size = optional(number, null) + timeout = optional(number, null) + }), {}) + config = optional(object({ + tokenPath = optional(string, null) + minimumDaysOld = optional(number, null) + dryRun = optional(bool, null) + }), {}) }), {}) }), {}) observability = optional(object({ logs = optional(object({ - tags = optional(map(string), {}) + level = optional(string, null) + retention_in_days = optional(number, null) + kms_key_id = optional(string, null) + class = optional(string, null) + tags = optional(map(string), {}) + }), {}) + tracing = optional(object({ + mode = optional(string, null) + capture_http_requests = optional(bool, null) + capture_error = optional(bool, null) + }), {}) + metrics = optional(object({ + enable = optional(bool, null) + namespace = optional(string, null) + metric = optional(object({ + enable_github_app_rate_limit = optional(bool, null) + enable_job_retry = optional(bool, null) + }), {}) }), {}) }), {}) compute_provider = object({ - ec2 = optional(object({ - metadata_options = optional(object({ - instance_metadata_tags = optional(string, "enabled") - http_endpoint = optional(string, "enabled") - http_tokens = optional(string, "required") - http_put_response_hop_limit = optional(number, 1) - }), {}) - ami = optional(object({ - filter = optional(map(list(string)), { state = ["available"] }) - owners = optional(list(string), ["amazon"]) - id_ssm_parameter = optional(object({ - arn = string + aws = optional(object({ + ec2 = optional(object({ + metadata_options = optional(object({ + instance_metadata_tags = optional(string, "enabled") + http_endpoint = optional(string, "enabled") + http_tokens = optional(string, "required") + http_put_response_hop_limit = optional(number, 1) + }), {}) + ami = optional(object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter = optional(object({ + arn = string + }), null) + kms_key = optional(object({ + arn = string + }), null) }), null) - kms_key = optional(object({ - arn = string + block_device_mappings = optional(list(object({ + delete_on_termination = optional(bool, true) + device_name = optional(string, "/dev/xvda") + encrypted = optional(bool, true) + iops = optional(number) + kms_key_id = optional(string) + snapshot_id = optional(string) + throughput = optional(number) + volume_initialization_rate = optional(number) + volume_size = number + volume_type = optional(string, "gp3") + })), [{ + volume_size = 30 + }]) + create_service_linked_role_spot = optional(bool, false) + credit_specification = optional(string, null) + ebs_optimized = optional(bool, false) + cloudwatch_agent = optional(object({ + enabled = optional(bool, true) + config = optional(string, null) + }), {}) + binaries_syncer = optional(object({ + enabled = optional(bool, null) + }), {}) + detailed_monitoring_enabled = optional(bool, false) + ssm_enabled = optional(bool, false) + user_data = optional(object({ + enabled = optional(bool, true) + template = optional(string, null) + content = optional(string, null) + pre_install = optional(string, "") + post_install = optional(string, "") + debug_logging_enabled = optional(bool, false) + }), {}) + instance_allocation_strategy = optional(string, "lowest-price") + instance_max_spot_price = optional(string, null) + instance_target_capacity_type = optional(string, "spot") + instance_type_priorities = optional(map(number), null) + instance_types = list(string) + additional_security_group_ids = optional(list(string), null) + managed_security_group_enabled = optional(bool, null) + egress_rules = optional(list(object({ + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + prefix_list_ids = list(string) + from_port = number + protocol = string + security_groups = list(string) + self = bool + to_port = number + description = string + })), null) + instance_profile_path = optional(string, null) + key_name = optional(string, null) + associate_public_ipv4_address = optional(bool, null) + instance_profile = optional(object({ + name = string }), null) + enable_on_demand_failover_for_errors = optional(list(string), []) + scale_errors = optional(list(string), [ + "UnfulfillableCapacity", + "MaxSpotInstanceCountExceeded", + "TargetCapacityLimitExceededException", + "RequestLimitExceeded", + "ResourceLimitExceeded", + "MaxSpotInstanceCountExceeded", + "MaxSpotFleetRequestCountExceeded", + "InsufficientInstanceCapacity", + "InsufficientCapacityOnHost", + ]) + subnet_ids = optional(list(string), null) + vpc_id = optional(string, null) + cpu_options = optional(object({ + core_count = optional(number) + threads_per_core = optional(number) + amd_sev_snp = optional(string) + nested_virtualization = optional(string) + }), null) + placement = optional(object({ + affinity = optional(string) + availability_zone = optional(string) + group_id = optional(string) + group_name = optional(string) + host_id = optional(string) + host_resource_group_arn = optional(string) + spread_domain = optional(string) + tenancy = optional(string) + partition_number = optional(number) + }), null) + license_specifications = optional(list(object({ + license_configuration_arn = string + })), []) + use_dedicated_host = optional(bool, false) + log_files = optional(list(object({ + log_group_name = string + prefix_log_group = bool + file_path = string + log_stream_name = string + log_class = optional(string, "STANDARD") + })), null) + tags = optional(map(string), {}) }), null) - block_device_mappings = optional(list(object({ - delete_on_termination = optional(bool, true) - device_name = optional(string, "/dev/xvda") - encrypted = optional(bool, true) - iops = optional(number) - kms_key_id = optional(string) - snapshot_id = optional(string) - throughput = optional(number) - volume_initialization_rate = optional(number) - volume_size = number - volume_type = optional(string, "gp3") - })), [{ - volume_size = 30 - }]) - create_service_linked_role_spot = optional(bool, false) - credit_specification = optional(string, null) - ebs_optimized = optional(bool, false) - cloudwatch_agent = optional(object({ - enabled = optional(bool, true) - config = optional(string, null) - }), {}) - binaries_syncer = optional(object({ - enabled = optional(bool, true) - }), {}) - detailed_monitoring_enabled = optional(bool, false) - ssm_enabled = optional(bool, false) - user_data = optional(object({ - enabled = optional(bool, true) - template = optional(string, null) - content = optional(string, null) - pre_install = optional(string, "") - post_install = optional(string, "") - debug_logging_enabled = optional(bool, false) - }), {}) - instance_allocation_strategy = optional(string, "lowest-price") - instance_max_spot_price = optional(string, null) - instance_target_capacity_type = optional(string, "spot") - instance_type_priorities = optional(map(number), null) - instance_types = list(string) - additional_security_group_ids = optional(list(string), []) - instance_profile = optional(object({ - name = string - }), null) - enable_on_demand_failover_for_errors = optional(list(string), []) - scale_errors = optional(list(string), [ - "UnfulfillableCapacity", - "MaxSpotInstanceCountExceeded", - "TargetCapacityLimitExceededException", - "RequestLimitExceeded", - "ResourceLimitExceeded", - "MaxSpotInstanceCountExceeded", - "MaxSpotFleetRequestCountExceeded", - "InsufficientInstanceCapacity", - "InsufficientCapacityOnHost", - ]) - subnet_ids = optional(list(string), null) - vpc_id = optional(string, null) - cpu_options = optional(object({ - core_count = optional(number) - threads_per_core = optional(number) - amd_sev_snp = optional(string) - nested_virtualization = optional(string) - }), null) - placement = optional(object({ - affinity = optional(string) - availability_zone = optional(string) - group_id = optional(string) - group_name = optional(string) - host_id = optional(string) - host_resource_group_arn = optional(string) - spread_domain = optional(string) - tenancy = optional(string) - partition_number = optional(number) - }), null) - license_specifications = optional(list(object({ - license_configuration_arn = string - })), []) - use_dedicated_host = optional(bool, false) - log_files = optional(list(object({ - log_group_name = string - prefix_log_group = bool - file_path = string - log_stream_name = string - log_class = optional(string, "STANDARD") - })), null) - tags = optional(map(string), {}) - }), null) + }), {}) }) - matcherConfig = object({ - labelMatchers = list(list(string)) - exactMatch = optional(bool, false) - bidirectionalLabelMatch = optional(bool, false) - priority = optional(number, 999) - enableDynamicLabels = optional(bool, false) - awsDynamicLabelsPolicy = optional(object({ - blocked_keys = optional(list(string), []) - restricted_keys = optional(map(object({ - allowed = optional(list(string), []) - denied = optional(list(string), []) - max = optional(string, null) - })), {}) - }), null) - }) })) }) validation { condition = alltrue([ for runner_config in values(var.ec2_deployment_specs.runner_specs) : - try( - length(runner_config.compute_provider.ec2[*]) == 1 - && length(runner_config.compute_provider.ec2.ami[*]) == 1 - && length(runner_config.compute_provider.ec2.ami.id_ssm_parameter[*]) == 0, - false, - ) + length([ + for provider_config in values(runner_config.orchestration_provider) : provider_config + if provider_config != null + ]) == 1 ]) - error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." - } - - validation { - condition = alltrue([ - for runner_config in values(var.ec2_deployment_specs.runner_specs) : - try(!runner_config.compute_provider.ec2.user_data.debug_logging_enabled, false) - ]) - error_message = "Forge EC2 runner_specs do not support user_data.debug_logging_enabled while the upstream v1 adapter is active." + error_message = "Each Forge runner configuration must select exactly one non-null orchestration provider." } validation { condition = alltrue([ for runner_config in values(var.ec2_deployment_specs.runner_specs) : try( - length(runner_config.compute_provider.ec2.instance_profile[*]) == 0 - && length(runner_config.runner.iam.role[*]) == 0, + length(runner_config.compute_provider.aws.ec2[*]) == 1 + && length(runner_config.compute_provider.aws.ec2.ami[*]) == 1 + && length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0, false, ) ]) - error_message = "Forge EC2 runner_specs do not support external runner.iam.role or compute_provider.ec2.instance_profile ownership while the upstream v1 adapter is active." - } - - validation { - condition = alltrue([ - for runner_config in values(var.ec2_deployment_specs.runner_specs) : - length(runner_config.tags) == 0 - && length(runner_config.runner.tags) == 0 - && length(runner_config.lambda.tags) == 0 - && length(runner_config.queue.tags) == 0 - && length(runner_config.scale_up.tags) == 0 - && length(runner_config.scale_down.tags) == 0 - && length(runner_config.pool.tags) == 0 - && length(runner_config.job_retry.tags) == 0 - && length(runner_config.ssm.tags) == 0 - && length(runner_config.ssm.parameters.tags) == 0 - && length(runner_config.ssm.housekeeper.tags) == 0 - && length(runner_config.observability.logs.tags) == 0 - ]) - error_message = "Forge EC2 runner_specs only support compute_provider.ec2.tags while the upstream v1 adapter is active; all other v2 per-lane tag maps must remain empty." - } - - validation { - condition = alltrue([ - for runner_config in values(var.ec2_deployment_specs.runner_specs) : - runner_config.runner.iam.additional_trust_policy_json == null - && runner_config.runner.iam.path == null - && runner_config.runner.iam.permissions_boundary == null - && runner_config.job_retry.lambda.reserved_concurrent_executions == 1 - && runner_config.ssm.kms_key == null - ]) - error_message = "Forge EC2 runner_specs do not support per-lane IAM trust/path/boundary, non-default job-retry Lambda reserved concurrency, or per-lane SSM KMS keys while the upstream v1 adapter is active." + error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported." } description = <<-EOT EC2 deployment configuration for GitHub Actions runners. The public runner - shape follows the nested v2 EC2 contract and is translated internally to the - released upstream v1 multi_runner_config interface. + shape follows the nested experimental multi_runner_config EC2 contract and + is passed directly to the upstream provider-oriented runner stack. Top-level fields: - lambda_subnet_ids: Subnets where runner-related lambdas execute. These can be more permissive than the runner subnets. - subnet_ids : Default subnets for EC2 runners. - vpc_id : VPC that contains both runner and lambda subnets. - - runner_specs : Map of EC2 runner lanes. + - runner_specs : Map of EC2 runner configurations. runner_specs[*] object fields: - - runner : Provider-neutral OS, architecture, labels, registration, - hooks, capacity, and IAM-policy configuration. - - github : Organization-versus-repository runner registration. - - queue : Webhook delay, retention, Lambda batching, and DLQ - redrive configuration. - - scale_up : Scale-up concurrency and queued-job checks. - - scale_down : Scale-down schedule, minimum runtime, and idle runners. - - pool : Scheduled warm-pool sizes and runner owner. - - job_retry : Retry timing, attempts, and Lambda sizing. - - matcherConfig : Static and dynamic GitHub label matching. - - compute_provider: Nested v2-compatible EC2 provider configuration. - - tags/lambda/ssm/observability: Included for v2 contract compatibility. - Tag scopes and per-lane SSM KMS settings that cannot be - represented by v1 must retain their defaults. - - compute_provider.ec2 fields: + - runner : Provider-neutral OS, architecture, labels, bootstrap, + hooks, and IAM-policy configuration. + - lambda : Provider-neutral per-configuration Lambda runtime, + network, role, and tag overrides. + - orchestration_provider : Exactly one demand-controller provider. The webhook + provider owns runner lifecycle, capacity, and startup + timing, GitHub scope, matching, queues, scaling, pools, + and job retry. + - ssm : Per-configuration paths, tags, parameter tags, and + housekeeper settings, including its Lambda artifact. + - observability : Per-configuration logging, tracing, and metrics overrides. + - compute_provider : Nested v2 EC2 provider configuration. + - tags : Per-configuration resource tags. + + compute_provider.aws.ec2 fields: - ami : Upstream-compatible EC2 AMI configuration. Forge requires a module-managed AMI block; null and external AMI parameter ownership are unsupported. - metadata_options: EC2 instance metadata service configuration. - block_device_mappings: EBS mappings for runner instances. - cloudwatch_agent/binaries_syncer/user_data: Runner bootstrap configuration. - user_data.debug_logging_enabled must remain false while - the stable upstream v1 adapter is active. - instance_types and allocation fields: EC2 Fleet capacity configuration. - - vpc_id/subnet_ids/additional_security_group_ids: Per-lane networking. + - vpc_id/subnet_ids/security-group fields: Per-configuration networking. - cpu_options/placement/license_specifications: EC2 launch-template options. - - instance_profile: Upstream contract field reserved for future Forge support. + - instance_profile: Optional externally managed instance profile. It requires + an externally managed runner IAM role whose owner also + supplies Forge's EC2-tag and hook-SSM permissions. - log_files/tags : EC2 logging and resource tags. - - The v7.10.1 compatibility adapter also requires external runner IAM ownership, - per-lane IAM trust/path/boundary settings, and non-default job-retry Lambda - reserved concurrency to remain unset. These constraints prevent accepted v2 - settings from being silently discarded by the stable v1 module. EOT } diff --git a/tests/iac/terraform_contract_test.py b/tests/iac/terraform_contract_test.py index af05af11..7990dda9 100644 --- a/tests/iac/terraform_contract_test.py +++ b/tests/iac/terraform_contract_test.py @@ -398,7 +398,13 @@ def test_runner_webhook_enables_upstream_api_gateway_access_logs() -> None: deployment_tf = read_repo_file( 'modules/platform/ec2_deployment/main.tf' ) + runner_configs_tf = read_repo_file( + 'modules/platform/ec2_deployment/runner_configs_v2.tf' + ) runner_module = hcl_block(deployment_tf, 'module', 'runners') + experimental_config = assignment_map_block( + runner_configs_tf, 'experimental_config' + ) log_group = hcl_block( deployment_tf, 'resource', @@ -418,10 +424,16 @@ def test_runner_webhook_enables_upstream_api_gateway_access_logs() -> None: 'integrationErrorMessage = "$context.integrationErrorMessage"', ], ) - assert_contains_all( + assert re.search( + r'^\s*experimental\s*=\s*local\.experimental_config$', runner_module, + re.MULTILINE, + ) + assert_contains_all( + experimental_config, [ - 'webhook_lambda_apigateway_access_log_settings = {', + 'orchestration_provider = {', + 'api_gateway_access_log_settings = {', 'destination_arn = aws_cloudwatch_log_group.webhook_api_gateway_access.arn', 'format = local.webhook_api_gateway_access_log_format', ],