diff --git a/modules/platform/ec2_deployment/README.md b/modules/platform/ec2_deployment/README.md index bbd420df1..c4018b71b 100644 --- a/modules/platform/ec2_deployment/README.md +++ b/modules/platform/ec2_deployment/README.md @@ -1,36 +1,45 @@ -# EC2 Runner Deployment +# Runner Deployment (EC2 and Lambda MicroVM) -This module deploys Forge EC2 runner pools through the upstream +This module deploys Forge runner pools through the upstream `terraform-aws-github-runner` multi-runner module. ## Why This Module Exists -The experimental input lets each runner configuration select a typed +The multi-runner 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`. +through `multi_runner_config`. -Global experimental settings own the GitHub App and API client, common Lambda +Each lane selects exactly one AWS compute provider. EC2 lanes receive Forge's +instance bootstrap, lifecycle hooks, tag permissions, AMI refresh, and helper +Lambdas. Lambda MicroVM lanes keep their caller-provided hooks and provider +configuration and do not receive those EC2-only overlays. + +Global 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. +Lambda MicroVM supports Linux ARM64 ephemeral runners with image, network +connector, logging, duration, environment, and provider-IAM settings. ## What It Manages - The upstream multi-runner control plane for webhook, scale-up, scale-down, and ephemeral runner registration. - Per-configuration label matching, warm pool schedules, and capacity limits. - EC2 AMI, instance type, storage, user data, tag, and logging-hook configuration. +- Lambda MicroVM image, connector, runtime logging, duration, and provider-IAM 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`, `orchestration_provider.webhook`, and - `compute_provider.aws.ec2`. The `orchestration_provider.webhook.runner` block owns the + exactly one of `compute_provider.aws.ec2` or + `compute_provider.aws.microvm`. 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 @@ -38,6 +47,24 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. - 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. +- Lambda MicroVM lanes require + `runner_configs.lambda_artifacts.control_plane_zip` and + `runner_configs.lambda_artifacts.webhook_zip`. Build both archives from the + same `fix-multi-runner-v2-routing` revision; the upstream branch does not + publish deployable archives. The expected build outputs are + `lambdas/functions/control-plane/runners.zip` and + `lambdas/functions/webhook/webhook.zip`. +- EC2 lanes retain the v7.10.1 release downloads for the runner control plane, + webhook, and binary syncer. Empty runner maps and omitted artifact fields + also retain that release fallback. A MicroVM-only deployment with both + caller archives skips the release download. +- Lambda MicroVM requires `runner.os = "linux"`, + `runner.architecture = "arm64"`, and webhook orchestration with ephemeral and + JIT runner configuration enabled. +- The upstream MicroVM provider creates one runtime CloudWatch log group per + runner configuration. Callers configure retention, encryption, class, and + tags through the existing `observability.logs` block instead of supplying a + log-group name under the compute provider. - The SSM housekeeper has its own artifact selector under `ssm.housekeeper.lambda.artifact`; Forge points the global selector at the runner control-plane archive. @@ -55,9 +82,9 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. 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. +- The upstream Terraform ref is the mutable + `fix-multi-runner-v2-routing` branch. Keep Terraform and Lambda artifacts + pinned to the same reviewed revision for rollout. - 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. @@ -114,7 +141,7 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles. | ---- | ----------- | ---- | ------- | :------: | | [aws\_region](#input\_aws\_region) | Assuming single region for now. | `string` | n/a | yes | | [network\_configs](#input\_network\_configs) | n/a |
object({
vpc_id = string
subnet_ids = list(string)
lambda_vpc_id = string
lambda_subnet_ids = list(string)
}) | n/a | yes |
-| [runner\_configs](#input\_runner\_configs) | n/a | object({
env = string
prefix = string
ghes_url = string
log_level = string
logging_retention_in_days = string
github_app = object({
key_base64 = string
id = string
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
runner_specs = map(object({
tags = optional(map(string), {})
runner = object({
os = string
architecture = string
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 = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
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)
dynamic_labels_enabled = optional(bool, null)
# Deprecated compatibility alias; normalize it to the upstream
# dynamic_labels_enabled field before invoking the runner module.
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({
enabled = optional(bool, null)
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
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)
on_demand_failover_for_errors = optional(list(string), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
}), {})
})
}))
}) | n/a | yes |
+| [runner\_configs](#input\_runner\_configs) | Forge runner deployment configuration. lambda\_artifacts.control\_plane\_zip andobject({
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)
lambda_artifacts = optional(object({
control_plane_zip = optional(string, null)
webhook_zip = optional(string, null)
}), {})
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)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
microvms = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
})
}))
}) | n/a | yes |
| [tenant\_configs](#input\_tenant\_configs) | n/a | object({
ecr_registries = list(string)
tags = map(string)
}) | n/a | yes |
## Outputs
@@ -125,6 +152,11 @@ custom AMIs, macOS/Windows, dedicated hosts, and larger hardware profiles.
| [ec2\_runners\_arn\_map](#output\_ec2\_runners\_arn\_map) | Map of EC2 runner keys to their IAM role ARNs. |
| [ec2\_runners\_labels\_map](#output\_ec2\_runners\_labels\_map) | Map of EC2 runner keys to their base and extra GitHub labels. |
| [event\_bus\_name](#output\_event\_bus\_name) | Name of the EventBridge event bus used by the webhook relay. |
+| [microvm\_runners\_arn\_map](#output\_microvm\_runners\_arn\_map) | Map of Lambda MicroVM runner keys to their execution-role ARNs. |
+| [microvm\_runners\_labels\_map](#output\_microvm\_runners\_labels\_map) | Map of Lambda MicroVM runner keys to their base and extra GitHub labels. |
+| [microvm\_runners\_map](#output\_microvm\_runners\_map) | Map of Lambda MicroVM runner keys to their provider-owned image, execution-role, and runtime log-group outputs. |
+| [runners\_arn\_map](#output\_runners\_arn\_map) | Map of runner keys to their resolved EC2 or Lambda MicroVM execution-role ARNs. |
+| [runners\_labels\_map](#output\_runners\_labels\_map) | Map of runner keys to their base and extra GitHub labels. |
| [subnet\_cidr\_blocks](#output\_subnet\_cidr\_blocks) | Map of EC2 runner subnet IDs to their CIDR blocks. |
| [webhook\_endpoint](#output\_webhook\_endpoint) | Public HTTPS endpoint URL for the GitHub Actions webhook relay. |
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 e24ff1de5..b9fa10398 100644
--- a/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf
+++ b/modules/platform/ec2_deployment/ec2_update_runner_ssm_ami.tf
@@ -36,6 +36,7 @@ locals {
}
module "ec2_update_runner_ssm_ami" {
+ count = length(local.ec2_runner_configs) > 0 ? 1 : 0
source = "./ec2_update_runner_ssm_ami"
providers = {
diff --git a/modules/platform/ec2_deployment/ec2_update_runner_tags.tf b/modules/platform/ec2_deployment/ec2_update_runner_tags.tf
index 3c35f4145..60380bc02 100644
--- a/modules/platform/ec2_deployment/ec2_update_runner_tags.tf
+++ b/modules/platform/ec2_deployment/ec2_update_runner_tags.tf
@@ -1,4 +1,5 @@
module "ec2_update_runner_tags" {
+ count = length(local.ec2_runner_configs) > 0 ? 1 : 0
source = "./ec2_update_runner_tags"
providers = {
diff --git a/modules/platform/ec2_deployment/main.tf b/modules/platform/ec2_deployment/main.tf
index 416f5d043..a14cbb03f 100644
--- a/modules/platform/ec2_deployment/main.tf
+++ b/modules/platform/ec2_deployment/main.tf
@@ -5,7 +5,7 @@ locals {
terraform_aws_github_runner_tags = merge(
var.tenant_configs.tags,
{
- terraform-aws-github-runner-ref = "v7.11.0"
+ terraform-aws-github-runner-ref = "fix-multi-runner-v2-routing"
}
)
webhook_api_gateway_access_log_format = jsonencode({
@@ -67,7 +67,13 @@ data "aws_subnet" "runner_subnet" {
}
data "external" "download_lambdas" {
- program = ["bash", "${path.module}/scripts/download_lambdas.sh", "/tmp/${var.runner_configs.prefix}/", "v7.11.0", "github-aws-runners/terraform-aws-github-runner"]
+ count = (
+ length(local.ec2_runner_configs) > 0
+ || try(var.runner_configs.lambda_artifacts.control_plane_zip, null) == null
+ || try(var.runner_configs.lambda_artifacts.webhook_zip, null) == null
+ ) ? 1 : 0
+
+ program = ["bash", "${path.module}/scripts/download_lambdas.sh", "/tmp/${var.runner_configs.prefix}/v7.10.1/", "v7.10.1", "github-aws-runners/terraform-aws-github-runner"]
}
# ---------------------------------------------------------------------------
@@ -105,6 +111,8 @@ resource "aws_ssm_parameter" "hook_job_completed" {
}
data "aws_iam_policy_document" "runner_hooks_ssm_read" {
+ count = length(local.ec2_runner_configs) > 0 ? 1 : 0
+
statement {
sid = "ReadRunnerHookParameters"
effect = "Allow"
@@ -117,9 +125,11 @@ data "aws_iam_policy_document" "runner_hooks_ssm_read" {
}
resource "aws_iam_policy" "runner_hooks_ssm_read" {
+ count = length(local.ec2_runner_configs) > 0 ? 1 : 0
name = "${var.runner_configs.prefix}-runner-hooks-ssm-read"
+ path = "/"
description = "Allow runners to read their gzip'd job-hook scripts from SSM."
- policy = data.aws_iam_policy_document.runner_hooks_ssm_read.json
+ policy = data.aws_iam_policy_document.runner_hooks_ssm_read[0].json
tags = var.tenant_configs.tags
}
@@ -136,18 +146,18 @@ module "runners" {
github_app = var.runner_configs.github_app
- multi_runner_config = {}
+ experimental_features = ["multi-runner-v2"]
- experimental_global_config = {
- tags = local.experimental_config.tags
+ global_config = {
+ tags = local.global_config.tags
}
- experimental_global_config_github = local.experimental_config.github
- experimental_global_config_lambda = local.experimental_config.lambda
- experimental_global_config_orchestration_provider = local.experimental_config.orchestration_provider
- experimental_global_config_ssm = local.experimental_config.ssm
- experimental_global_config_observability = local.experimental_config.observability
- experimental_global_config_compute_provider = local.experimental_config.compute_provider
- experimental_multi_runner_config = local.multi_runner_config
+ global_config_github = local.global_config.github
+ global_config_lambda = local.global_config.lambda
+ global_config_orchestration_provider = local.global_config.orchestration_provider
+ global_config_ssm = local.global_config.ssm
+ global_config_observability = local.global_config.observability
+ global_config_compute_provider = local.global_config.compute_provider
+ multi_runner_config = local.multi_runner_config
depends_on = [
data.external.download_lambdas,
diff --git a/modules/platform/ec2_deployment/moved.tf b/modules/platform/ec2_deployment/moved.tf
new file mode 100644
index 000000000..71a1f752a
--- /dev/null
+++ b/modules/platform/ec2_deployment/moved.tf
@@ -0,0 +1,19 @@
+moved {
+ from = aws_iam_policy.ec2_tags
+ to = aws_iam_policy.ec2_tags[0]
+}
+
+moved {
+ from = aws_iam_policy.runner_hooks_ssm_read
+ to = aws_iam_policy.runner_hooks_ssm_read[0]
+}
+
+moved {
+ from = module.ec2_update_runner_ssm_ami
+ to = module.ec2_update_runner_ssm_ami[0]
+}
+
+moved {
+ from = module.ec2_update_runner_tags
+ to = module.ec2_update_runner_tags[0]
+}
diff --git a/modules/platform/ec2_deployment/outputs.tf b/modules/platform/ec2_deployment/outputs.tf
index fe12184d4..3798f5b22 100644
--- a/modules/platform/ec2_deployment/outputs.tf
+++ b/modules/platform/ec2_deployment/outputs.tf
@@ -5,14 +5,25 @@ output "webhook_endpoint" {
output "ec2_runners_arn_map" {
value = {
- for runner_key, runner in module.runners.runners_map_v2 : runner_key => coalesce(
- try(runner.runner.role.arn, null),
+ for runner_key in keys(local.ec2_runner_configs) : runner_key => coalesce(
+ try(module.runners.runners_map_v2[runner_key].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."
}
+output "runners_arn_map" {
+ value = {
+ for runner_key, runner in module.runners.runners_map_v2 : runner_key => coalesce(
+ try(runner.runner.role.arn, null),
+ try(runner.provider.aws.microvm.execution_role_arn, null),
+ try(local.runner_configs[runner_key].runner.iam.role.arn, null),
+ )
+ }
+ description = "Map of runner keys to their resolved EC2 or Lambda MicroVM execution-role ARNs."
+}
+
output "ec2_runners_ami_name_map" {
value = {
for runner_key in keys(local.ec2_runner_configs) : runner_key => data.aws_ami.runner_ami[runner_key].name
@@ -27,6 +38,35 @@ output "ec2_runners_labels_map" {
description = "Map of EC2 runner keys to their base and extra GitHub labels."
}
+output "runners_labels_map" {
+ value = local.runner_labels
+ description = "Map of runner keys to their base and extra GitHub labels."
+}
+
+output "microvm_runners_arn_map" {
+ value = {
+ for runner_key in keys(local.microvm_runner_configs) :
+ runner_key => module.runners.runners_map_v2[runner_key].provider.aws.microvm.execution_role_arn
+ }
+ description = "Map of Lambda MicroVM runner keys to their execution-role ARNs."
+}
+
+output "microvm_runners_labels_map" {
+ value = {
+ for runner_key in keys(local.microvm_runner_configs) :
+ runner_key => local.runner_labels[runner_key]
+ }
+ description = "Map of Lambda MicroVM runner keys to their base and extra GitHub labels."
+}
+
+output "microvm_runners_map" {
+ value = {
+ for runner_key in keys(local.microvm_runner_configs) :
+ runner_key => module.runners.runners_map_v2[runner_key].provider.aws.microvm
+ }
+ description = "Map of Lambda MicroVM runner keys to their provider-owned image, execution-role, and runtime log-group outputs."
+}
+
output "subnet_cidr_blocks" {
value = { for id, subnet in data.aws_subnet.runner_subnet : id => subnet.cidr_block }
description = "Map of EC2 runner subnet IDs to their CIDR blocks."
diff --git a/modules/platform/ec2_deployment/roles.tf b/modules/platform/ec2_deployment/roles.tf
index ff708b96b..6dccf81b8 100644
--- a/modules/platform/ec2_deployment/roles.tf
+++ b/modules/platform/ec2_deployment/roles.tf
@@ -32,7 +32,9 @@ data "aws_iam_policy_document" "ec2_tags" {
# Define the actual IAM policy for EC2 tags
resource "aws_iam_policy" "ec2_tags" {
+ count = length(local.ec2_runner_configs) > 0 ? 1 : 0
name = "${var.runner_configs.prefix}-policy-for-ec2-tags"
+ path = "/"
description = "Policy that allows EC2 instances to create tags on themselves."
policy = data.aws_iam_policy_document.ec2_tags.json
diff --git a/modules/platform/ec2_deployment/runner_configs_v2.tf b/modules/platform/ec2_deployment/runner_configs_v2.tf
index 6208fb177..ee081872b 100644
--- a/modules/platform/ec2_deployment/runner_configs_v2.tf
+++ b/modules/platform/ec2_deployment/runner_configs_v2.tf
@@ -1,5 +1,39 @@
locals {
- ec2_runner_configs = var.runner_configs.runner_specs
+ runner_configs = var.runner_configs.runner_specs
+ runner_binaries_default_enabled = true
+ runner_iam_policy_arn_prefix = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy"
+ runner_binaries_targets = {
+ for target in distinct([
+ for runner_config in values(var.runner_configs.runner_specs) : {
+ os = runner_config.runner.os
+ architecture = runner_config.runner.architecture
+ }
+ if(
+ length(try(runner_config.compute_provider.aws.ec2[*], [])) == 1
+ && coalesce(
+ try(runner_config.compute_provider.aws.ec2.binaries_syncer.enabled, null),
+ local.runner_binaries_default_enabled,
+ )
+ )
+ ]) : "${target.os}_${target.architecture}" => target
+ }
+ compute_provider_selections = {
+ for runner_key, runner_config in var.runner_configs.runner_specs : runner_key => {
+ namespace = "aws"
+ type = (
+ length(try(runner_config.compute_provider.aws.ec2[*], [])) == 1 ? "ec2" :
+ length(try(runner_config.compute_provider.aws.microvm[*], [])) == 1 ? "microvm" : null
+ )
+ }
+ }
+ ec2_runner_configs = {
+ for key, runner_config in local.runner_configs : key => runner_config
+ if length(try(runner_config.compute_provider.aws.ec2[*], [])) == 1
+ }
+ microvm_runner_configs = {
+ for key, runner_config in local.runner_configs : key => runner_config
+ if length(try(runner_config.compute_provider.aws.microvm[*], [])) == 1
+ }
effective_runner_users = {
for key, runner_config in local.ec2_runner_configs :
@@ -89,7 +123,7 @@ locals {
}
legacy_runner_labels = {
- for key, runner_config in local.ec2_runner_configs :
+ for key, runner_config in local.runner_configs :
key => concat(
try(runner_config.orchestration_provider.webhook.matcherConfig.labelMatchers[0], []),
coalesce(runner_config.runner.extra_labels, []),
@@ -100,7 +134,7 @@ locals {
# only new labels introduced by additional v2 matchers. The upstream v1 module
# separately sorts the corresponding set before registering runners.
runner_labels = {
- for key, runner_config in local.ec2_runner_configs :
+ for key, runner_config in local.runner_configs :
key => concat(
local.legacy_runner_labels[key],
distinct([
@@ -181,15 +215,15 @@ locals {
}
)
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)
+ tags = runner_config.compute_provider.aws.ec2.tags
}
)
}
- # Preserve Forge-managed runner hooks, policies, bootstrap content, logging,
- # and tags while passing the public nested contract directly to upstream v2.
+ # Preserve the provider-neutral lane contract while applying Forge's EC2
+ # bootstrap, lifecycle-hook, and policy overlays only to EC2 lanes.
multi_runner_config = {
- for key, runner_config in local.ec2_runner_configs :
+ for key, runner_config in local.runner_configs :
key => merge(runner_config, {
observability = merge(runner_config.observability, {
metrics = merge(runner_config.observability.metrics, {
@@ -214,10 +248,10 @@ locals {
})
})
runner = merge(runner_config.runner, {
- hooks = {
+ hooks = contains(keys(local.ec2_runner_configs), key) ? {
job_started = local.runner_hook_job_started[key]
job_completed = local.runner_hook_job_completed[key]
- }
+ } : runner_config.runner.hooks
iam = merge(runner_config.runner.iam, {
managed_policy_arns = merge(
coalesce(runner_config.runner.iam.managed_policy_arns, {}),
@@ -226,10 +260,10 @@ locals {
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
- },
+ contains(keys(local.ec2_runner_configs), key) ? {
+ forge_ec2_tags = "${local.runner_iam_policy_arn_prefix}${aws_iam_policy.ec2_tags[0].path}${aws_iam_policy.ec2_tags[0].name}"
+ forge_runner_hooks_ssm_read = "${local.runner_iam_policy_arn_prefix}${aws_iam_policy.runner_hooks_ssm_read[0].path}${aws_iam_policy.runner_hooks_ssm_read[0].name}"
+ } : {},
) : {},
)
})
@@ -245,21 +279,24 @@ locals {
})
})
}
- compute_provider = {
- aws = {
- ec2 = merge(local.ec2_compute_provider[key], {
- on_demand_failover_for_errors = coalesce(
- try(runner_config.compute_provider.aws.ec2.on_demand_failover_for_errors, null),
- try(runner_config.compute_provider.aws.ec2.enable_on_demand_failover_for_errors, null),
- [],
- )
- })
- }
- }
+ compute_provider = merge(runner_config.compute_provider, {
+ aws = merge(runner_config.compute_provider.aws, {
+ ec2 = try(local.ec2_compute_provider[key], null)
+ })
+ })
})
}
- experimental_config = {
+ control_plane_lambda_zip = try(coalesce(
+ try(var.runner_configs.lambda_artifacts.control_plane_zip, null),
+ try("${data.external.download_lambdas[0].result.path}/runners.zip", null),
+ ), null)
+ webhook_lambda_zip = try(coalesce(
+ try(var.runner_configs.lambda_artifacts.webhook_zip, null),
+ try("${data.external.download_lambdas[0].result.path}/webhook.zip", null),
+ ), null)
+
+ global_config = {
tags = local.terraform_aws_github_runner_tags
github = {
@@ -282,11 +319,11 @@ locals {
}
lambda = {
artifact = {
- zip = "${data.external.download_lambdas.result.path}/runners.zip"
+ zip = local.control_plane_lambda_zip
}
webhook = {
artifact = {
- zip = "${data.external.download_lambdas.result.path}/webhook.zip"
+ zip = local.webhook_lambda_zip
}
api_gateway_access_log_settings = {
destination_arn = aws_cloudwatch_log_group.webhook_api_gateway_access.arn
@@ -305,7 +342,7 @@ locals {
housekeeper = {
lambda = {
artifact = {
- zip = "${data.external.download_lambdas.result.path}/runners.zip"
+ zip = local.control_plane_lambda_zip
}
}
}
@@ -319,14 +356,17 @@ locals {
}
compute_provider = {
+ selections = local.compute_provider_selections
aws = {
ec2 = {
vpc_id = var.network_configs.vpc_id
subnet_ids = var.network_configs.subnet_ids
runner_binaries = {
+ enabled = local.runner_binaries_default_enabled
+ targets = local.runner_binaries_targets
syncer = {
artifact = {
- zip = "${data.external.download_lambdas.result.path}/runner-binaries-syncer.zip"
+ zip = try("${data.external.download_lambdas[0].result.path}/runner-binaries-syncer.zip", null)
}
}
}
diff --git a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl
index f02c5d917..4b539ef8c 100644
--- a/modules/platform/ec2_deployment/tests/behavior.tftest.hcl
+++ b/modules/platform/ec2_deployment/tests/behavior.tftest.hcl
@@ -76,6 +76,18 @@ mock_provider "aws" {
url = "https://sqs.eu-west-1.amazonaws.com/123456789012/mock"
}
}
+
+ mock_resource "aws_lambda_function" {
+ defaults = {
+ arn = "arn:aws:lambda:eu-west-1:123456789012:function:mock"
+ }
+ }
+
+ mock_resource "aws_cloudwatch_event_rule" {
+ defaults = {
+ arn = "arn:aws:events:eu-west-1:123456789012:rule/mock"
+ }
+ }
}
mock_provider "external" {
@@ -124,6 +136,10 @@ variables {
webhook_secret = "test"
}
runner_iam_role_managed_policy_arns = []
+ lambda_artifacts = {
+ control_plane_zip = "README.md"
+ webhook_zip = "README.md"
+ }
runner_specs = {
ec2 = {
tags = { Scope = "entry" }
@@ -265,7 +281,7 @@ variables {
tags = { Scope = "ssm-housekeeper" }
lambda = {
artifact = {
- zip = "/private/tmp/forge-test-lambda-cache/ssm-housekeeper.zip"
+ zip = "README.md"
}
memory_size = 384
timeout = 70
@@ -399,6 +415,77 @@ variables {
}
}
}
+ microvm = {
+ tags = { Scope = "microvm-entry" }
+ runner = {
+ os = "linux"
+ architecture = "arm64"
+ extra_labels = ["arm64-extra"]
+ hooks = {
+ job_started = "echo microvm-started"
+ job_completed = "echo microvm-completed"
+ }
+ iam = {
+ managed_policy_arns = {
+ caller = "arn:aws:iam::123456789012:policy/microvm-caller"
+ }
+ }
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ ephemeral = true
+ jit_config_enabled = true
+ maximum_count = 3
+ }
+ matcherConfig = {
+ labelMatchers = [
+ ["self-hosted", "microvm"],
+ ["self-hosted", "lambda-microvm"],
+ ]
+ }
+ }
+ }
+ observability = {
+ logs = {
+ retention_in_days = 7
+ kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/33333333-3333-3333-3333-333333333333"
+ class = "INFREQUENT_ACCESS"
+ tags = { Scope = "microvm-logs" }
+ }
+ }
+ compute_provider = {
+ aws = {
+ microvm = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:forge-linux-arm64"
+ image_version = "3"
+ ingress_network_connectors = ["arn:aws:lambda:eu-west-1:123456789012:network-connector:ingress"]
+ egress_network_connectors = ["arn:aws:lambda:eu-west-1:123456789012:network-connector:egress"]
+ maximum_duration_in_seconds = 3600
+ environment_variables = {
+ FORGE_MICROVM = "true"
+ }
+ iam = {
+ resource_arns = {
+ images = ["arn:aws:lambda:eu-west-1:123456789012:microvm-image:forge-linux-arm64"]
+ microvms = ["arn:aws:lambda:eu-west-1:123456789012:microvm:*"]
+ }
+ additional_policy_json = {
+ scale_up = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ }
+ managed_policies = {
+ scale_up = {
+ arn = "arn:aws:iam::123456789012:policy/microvm-scale-up"
+ }
+ pool = {
+ arn = "arn:aws:iam::123456789012:policy/microvm-pool"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
@@ -414,21 +501,29 @@ run "ec2_v2_input_plan" {
}
assert {
- condition = toset(keys(local.ec2_runner_configs)) == toset(["default_path", "ec2"])
- error_message = "EC2 provider filtering must retain every EC2 runner configuration."
+ condition = (
+ length(local.ec2_runner_configs) == 2
+ && can(local.ec2_runner_configs.default_path)
+ && can(local.ec2_runner_configs.ec2)
+ && !can(local.ec2_runner_configs.microvm)
+ )
+ error_message = "EC2 provider filtering must retain EC2 lanes and exclude the Lambda MicroVM lane."
}
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."
+ condition = toset(keys(local.multi_runner_config)) == toset(["default_path", "ec2", "microvm"])
+ error_message = "The provider-neutral upstream v2 configuration must preserve every 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"])
+ && local.multi_runner_config.ec2.compute_provider.aws.ec2 != null
+ && local.multi_runner_config.ec2.compute_provider.aws.microvm == null
+ && local.multi_runner_config.microvm.compute_provider.aws.ec2 == null
+ && local.multi_runner_config.microvm.compute_provider.aws.microvm != null
)
- error_message = "Forge must preserve the AWS namespace while keeping ec2 as the runtime compute-provider type."
+ error_message = "Forge must preserve the AWS namespace and each lane's selected EC2 or Lambda MicroVM provider."
}
assert {
@@ -444,7 +539,58 @@ run "ec2_v2_input_plan" {
assert {
condition = local.active_ec2_subnet_ids == toset(["subnet-default", "subnet-override"])
- error_message = "EC2 effective subnet resolution must preserve per-configuration overrides."
+ error_message = "EC2 effective subnet resolution must preserve per-configuration overrides and exclude Lambda MicroVM lanes."
+ }
+
+ assert {
+ condition = (
+ length(local.ec2_default_ami_filters) == 2
+ && length(local.ec2_compute_provider) == 2
+ && length(local.effective_runner_users) == 2
+ && length(local.runner_hook_job_started) == 2
+ && !can(local.ec2_default_ami_filters.microvm)
+ && !can(local.ec2_compute_provider.microvm)
+ && !can(local.effective_runner_users.microvm)
+ && !can(local.runner_hook_job_started.microvm)
+ )
+ error_message = "AMI defaults, normalized EC2 providers, runner users, and Forge lifecycle hooks must remain EC2-only."
+ }
+
+ assert {
+ condition = (
+ local.multi_runner_config.microvm.runner.os == "linux"
+ && local.multi_runner_config.microvm.runner.architecture == "arm64"
+ && local.multi_runner_config.microvm.runner.hooks.job_started == "echo microvm-started"
+ && local.multi_runner_config.microvm.runner.hooks.job_completed == "echo microvm-completed"
+ && tolist(local.multi_runner_config.microvm.runner.extra_labels) == tolist(["arm64-extra"])
+ && tolist(local.multi_runner_config.microvm.orchestration_provider.webhook.matcherConfig.labelMatchers[0]) == tolist(["self-hosted", "microvm"])
+ && tolist(local.multi_runner_config.microvm.orchestration_provider.webhook.matcherConfig.labelMatchers[1]) == tolist(["self-hosted", "lambda-microvm"])
+ && local.multi_runner_config.microvm.runner.iam.managed_policy_arns.caller == "arn:aws:iam::123456789012:policy/microvm-caller"
+ && !contains(keys(local.multi_runner_config.microvm.runner.iam.managed_policy_arns), "forge_ec2_tags")
+ && !contains(keys(local.multi_runner_config.microvm.runner.iam.managed_policy_arns), "forge_runner_hooks_ssm_read")
+ )
+ error_message = "Lambda MicroVM lanes must preserve caller hooks and policies without receiving Forge's EC2-only overlays."
+ }
+
+ assert {
+ condition = (
+ local.multi_runner_config.microvm.orchestration_provider.webhook.runner.ephemeral
+ && local.multi_runner_config.microvm.orchestration_provider.webhook.runner.jit_config_enabled
+ && local.multi_runner_config.microvm.compute_provider.aws.microvm.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:forge-linux-arm64"
+ && local.multi_runner_config.microvm.compute_provider.aws.microvm.image_version == "3"
+ && local.multi_runner_config.microvm.compute_provider.aws.microvm.ingress_network_connectors[0] == "arn:aws:lambda:eu-west-1:123456789012:network-connector:ingress"
+ && local.multi_runner_config.microvm.compute_provider.aws.microvm.egress_network_connectors[0] == "arn:aws:lambda:eu-west-1:123456789012:network-connector:egress"
+ && !contains(keys(local.multi_runner_config.microvm.compute_provider.aws.microvm), "logging")
+ && local.multi_runner_config.microvm.observability.logs.retention_in_days == 7
+ && local.multi_runner_config.microvm.observability.logs.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/33333333-3333-3333-3333-333333333333"
+ && local.multi_runner_config.microvm.observability.logs.class == "INFREQUENT_ACCESS"
+ && local.multi_runner_config.microvm.observability.logs.tags.Scope == "microvm-logs"
+ && local.multi_runner_config.microvm.compute_provider.aws.microvm.maximum_duration_in_seconds == 3600
+ && local.multi_runner_config.microvm.compute_provider.aws.microvm.environment_variables.FORGE_MICROVM == "true"
+ && local.multi_runner_config.microvm.compute_provider.aws.microvm.iam.managed_policies.scale_up.arn == "arn:aws:iam::123456789012:policy/microvm-scale-up"
+ && local.multi_runner_config.microvm.compute_provider.aws.microvm.iam.managed_policies.pool.arn == "arn:aws:iam::123456789012:policy/microvm-pool"
+ )
+ error_message = "The normalized map must preserve the Lambda MicroVM provider contract without requiring a caller-selected log group."
}
assert {
@@ -548,10 +694,10 @@ run "ec2_v2_input_plan" {
&& 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"
+ && !contains(keys(local.multi_runner_config.ec2.compute_provider.aws.ec2.tags), "Environment")
&& local.multi_runner_config.ec2.compute_provider.aws.ec2.tags.Configuration == "ec2"
)
- error_message = "The v2 configuration must retain Forge user-data, logging, and tag overlays."
+ error_message = "The v2 configuration must retain Forge user-data and logging overlays while keeping EC2 provider tags lane-scoped."
}
assert {
@@ -574,10 +720,10 @@ run "ec2_v2_input_plan" {
"printf '%s' '${base64encode("echo caller-completed")}' | base64 --decode | bash\n",
)
&& 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"
+ && local.multi_runner_config.ec2.runner.iam.managed_policy_arns.forge_ec2_tags == "arn:aws:iam::123456789012:policy/forge-test-policy-for-ec2-tags"
+ && local.multi_runner_config.ec2.runner.iam.managed_policy_arns.forge_runner_hooks_ssm_read == "arn:aws:iam::123456789012:policy/forge-test-runner-hooks-ssm-read"
)
- error_message = "Caller hooks must be isolated from Forge's required lifecycle hooks, and Forge policies must augment caller policies."
+ error_message = "Caller hooks must be isolated from Forge's required lifecycle hooks, and plan-known Forge policy ARNs must augment caller policies."
}
assert {
@@ -641,7 +787,7 @@ run "ec2_v2_input_plan" {
&& local.multi_runner_config.ec2.ssm.paths.config == "custom-config"
&& local.multi_runner_config.ec2.ssm.housekeeper.schedule_expression == "rate(12 hours)"
&& local.multi_runner_config.ec2.ssm.housekeeper.state == "DISABLED"
- && local.multi_runner_config.ec2.ssm.housekeeper.lambda.artifact.zip == "/private/tmp/forge-test-lambda-cache/ssm-housekeeper.zip"
+ && local.multi_runner_config.ec2.ssm.housekeeper.lambda.artifact.zip == "README.md"
&& 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"
@@ -673,40 +819,390 @@ run "ec2_v2_input_plan" {
assert {
condition = (
- local.experimental_config.github.app.id == "12345"
- && local.experimental_config.github.enterprise_server.url == null
- && local.experimental_config.orchestration_provider.webhook.eventbridge.enabled
- && 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
+ local.global_config.github.app.id == "12345"
+ && local.global_config.github.enterprise_server.url == null
+ && local.global_config.orchestration_provider.webhook.eventbridge.enable
+ && local.global_config.orchestration_provider.webhook.lambda.artifact.zip == "README.md"
+ && tolist(local.global_config.lambda.subnet_ids) == tolist(["subnet-test"])
+ && length(local.global_config.lambda.security_group_ids) == 1
+ && local.global_config.orchestration_provider.webhook.lambda.webhook.artifact.zip == "README.md"
+ && local.global_config.ssm.kms_key_id == "arn:aws:kms:eu-west-1:123456789012:key/00000000-0000-0000-0000-000000000000"
+ && local.global_config.ssm.housekeeper.lambda.artifact.zip == "README.md"
+ && local.global_config.observability.logs.level == "info"
+ && local.global_config.observability.logs.retention_in_days == 3
+ && local.global_config.compute_provider.selections == {
+ default_path = { namespace = "aws", type = "ec2" }
+ ec2 = { namespace = "aws", type = "ec2" }
+ microvm = { namespace = "aws", type = "microvm" }
+ }
+ && local.global_config.compute_provider.aws.ec2.vpc_id == "vpc-test"
+ && tolist(local.global_config.compute_provider.aws.ec2.subnet_ids) == tolist(["subnet-default"])
+ && local.global_config.compute_provider.aws.ec2.runner_binaries.targets == {}
+ && local.global_config.compute_provider.aws.ec2.runner_binaries.syncer.artifact.zip == "/private/tmp/forge-test-lambda-cache/runner-binaries-syncer.zip"
+ && local.global_config.multi_runner_config == local.multi_runner_config
+ )
+ error_message = "Forge must populate the authoritative global contract."
+ }
+
+ assert {
+ condition = (
+ local.global_config.tags == local.terraform_aws_github_runner_tags
+ && local.global_config.tags.Environment == "test"
+ && local.global_config.lambda.tags == local.terraform_aws_github_runner_tags
+ && local.global_config.ssm.parameters.tags == local.terraform_aws_github_runner_tags
+ )
+ error_message = "Forge resource tags must be carried by the global, Lambda, and Parameter Store tag maps."
+ }
+
+ assert {
+ condition = (
+ local.global_config.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings.destination_arn == aws_cloudwatch_log_group.webhook_api_gateway_access.arn
+ && local.global_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 global webhook contract."
+ }
+
+}
+
+run "mixed_provider_outputs" {
+ command = plan
+
+ plan_options {
+ target = [
+ module.runners.module.runner_configs,
+ ]
+ }
+
+ assert {
+ condition = (
+ length(local.microvm_runner_configs) == 1
+ && can(local.microvm_runner_configs.microvm)
+ && output.microvm_runners_arn_map.microvm == "arn:aws:iam::123456789012:role/mock-runner"
+ && output.runners_arn_map.microvm == output.microvm_runners_arn_map.microvm
+ && output.microvm_runners_map.microvm.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:forge-linux-arm64"
+ && output.microvm_runners_map.microvm.runners_log_groups[0].name == "/github-self-hosted-runners/forge-test-microvm/microvm"
+ )
+ error_message = "Provider-neutral and Lambda MicroVM outputs must expose the resolved role, labels, image, and provider-managed runtime log group."
+ }
+}
+
+run "runner_binary_targets_are_static_provider_scoped_and_deduplicated" {
+ command = plan
+
+ variables {
+ runner_configs = {
+ env = "test"
+ prefix = "forge-test-runner-binary-targets"
+ ghes_url = ""
+ log_level = "info"
+ logging_retention_in_days = "3"
+ github_app = {
+ key_base64 = "dGVzdA=="
+ id = "12345"
+ webhook_secret = "test"
+ }
+ runner_iam_role_managed_policy_arns = []
+ lambda_artifacts = {
+ control_plane_zip = "README.md"
+ webhook_zip = "README.md"
+ }
+ runner_specs = {
+ enabled_explicit = {
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ maximum_count = 1
+ }
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "enabled-explicit"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ ami = {}
+ instance_types = ["m7i.large"]
+ binaries_syncer = {
+ enabled = true
+ }
+ }
+ }
+ }
+ }
+ enabled_inherited = {
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ maximum_count = 1
+ }
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "enabled-inherited"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ ami = {}
+ instance_types = ["m7i.large"]
+ }
+ }
+ }
+ }
+ disabled = {
+ runner = {
+ os = "linux"
+ architecture = "arm64"
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ maximum_count = 1
+ }
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "disabled"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ ami = {}
+ instance_types = ["m7g.large"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
+ }
+ }
+ }
+ microvm = {
+ runner = {
+ os = "linux"
+ architecture = "arm64"
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ ephemeral = true
+ jit_config_enabled = true
+ maximum_count = 1
+ }
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "microvm"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ microvm = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:forge-target-test"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ plan_options {
+ target = [
+ module.runners.terraform_data.validate_experimental,
+ ]
+ }
+
+ assert {
+ condition = local.runner_binaries_targets == {
+ linux_x64 = {
+ os = "linux"
+ architecture = "x64"
+ }
+ }
+ error_message = "Runner-binary targets must include each enabled EC2 OS and architecture once while excluding disabled EC2 and Lambda MicroVM lanes."
+ }
+
+ assert {
+ condition = local.global_config.compute_provider.aws.ec2.runner_binaries.targets == local.runner_binaries_targets
+ error_message = "Forge must pass the caller-known runner-binary target map through the global contract."
+ }
+
+ assert {
+ condition = local.global_config.compute_provider.selections == {
+ disabled = { namespace = "aws", type = "ec2" }
+ enabled_explicit = { namespace = "aws", type = "ec2" }
+ enabled_inherited = { namespace = "aws", type = "ec2" }
+ microvm = { namespace = "aws", type = "microvm" }
+ }
+ error_message = "Forge must pass a caller-known compute-provider selection for every runner lane."
+ }
+}
+
+run "microvm_only_plan" {
+ command = plan
+
+ variables {
+ runner_configs = {
+ env = "test"
+ prefix = "forge-test-microvm-only"
+ ghes_url = ""
+ log_level = "info"
+ logging_retention_in_days = "3"
+ github_app = {
+ key_base64 = "dGVzdA=="
+ id = "12345"
+ webhook_secret = "test"
+ }
+ runner_iam_role_managed_policy_arns = []
+ lambda_artifacts = {
+ control_plane_zip = "README.md"
+ webhook_zip = "README.md"
+ }
+ runner_specs = {
+ microvm_only = {
+ runner = {
+ os = "linux"
+ architecture = "arm64"
+ extra_labels = ["microvm-only"]
+ }
+ orchestration_provider = {
+ webhook = {
+ runner = {
+ ephemeral = true
+ jit_config_enabled = true
+ maximum_count = 1
+ }
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "microvm-only"]]
+ }
+ }
+ }
+ compute_provider = {
+ aws = {
+ microvm = {
+ image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:forge-microvm-only"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ plan_options {
+ target = [
+ aws_iam_policy.ec2_tags,
+ aws_iam_policy.runner_hooks_ssm_read,
+ data.aws_iam_policy_document.runner_hooks_ssm_read,
+ data.external.download_lambdas,
+ module.ec2_update_runner_ssm_ami,
+ module.runners.module.runner_configs,
+ ]
+ }
+
+ assert {
+ condition = (
+ length(local.ec2_runner_configs) == 0
+ && length(local.microvm_runner_configs) == 1
+ && can(local.microvm_runner_configs.microvm_only)
+ && length(local.ec2_compute_provider) == 0
+ && length(local.ec2_update_runner_ami_map) == 0
+ && length(local.runner_ssm_paths) == 0
+ && local.runner_binaries_targets == {}
+ && local.compute_provider_selections == {
+ microvm_only = { namespace = "aws", type = "microvm" }
+ }
+ && local.global_config.compute_provider.selections == local.compute_provider_selections
+ && local.global_config.compute_provider.aws.ec2.runner_binaries.targets == {}
)
- error_message = "Forge must populate the authoritative global experimental contract."
+ error_message = "A Lambda MicroVM-only deployment must leave every EC2-owned configuration and AMI helper input empty."
}
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
+ length(module.ec2_update_runner_ssm_ami) == 0
+ && length(module.ec2_update_runner_tags) == 0
+ && length(aws_iam_policy.ec2_tags) == 0
+ && length(aws_iam_policy.runner_hooks_ssm_read) == 0
+ && length(data.aws_iam_policy_document.runner_hooks_ssm_read) == 0
+ && length(data.aws_subnet.runner_subnet) == 0
+ && length(data.aws_ami.runner_ami) == 0
+ && length(data.aws_ssm_parameter.ami_id) == 0
+ && length(data.external.download_lambdas) == 0
+ && length(aws_ssm_parameter.hook_job_started) == 0
+ && length(aws_ssm_parameter.hook_job_completed) == 0
)
- error_message = "Forge resource tags must be carried by the experimental global, Lambda, and Parameter Store tag maps."
+ error_message = "A Lambda MicroVM-only deployment must not create EC2 policies, hooks, subnet or AMI lookups, or helper Lambdas."
}
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
+ toset(keys(local.multi_runner_config)) == toset(["microvm_only"])
+ && local.multi_runner_config.microvm_only.compute_provider.aws.ec2 == null
+ && local.multi_runner_config.microvm_only.compute_provider.aws.microvm.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:forge-microvm-only"
+ && local.multi_runner_config.microvm_only.orchestration_provider.webhook.runner.ephemeral
+ && local.multi_runner_config.microvm_only.orchestration_provider.webhook.runner.jit_config_enabled
+ && local.multi_runner_config.microvm_only.runner.hooks.job_started == null
+ && local.multi_runner_config.microvm_only.runner.hooks.job_completed == null
+ && !contains(keys(local.multi_runner_config.microvm_only.runner.iam.managed_policy_arns), "forge_ec2_tags")
+ && !contains(keys(local.multi_runner_config.microvm_only.runner.iam.managed_policy_arns), "forge_runner_hooks_ssm_read")
)
- error_message = "Forge webhook API Gateway access-log settings must be carried by the experimental webhook contract."
+ error_message = "A Lambda MicroVM-only lane must remain valid in the canonical map without EC2 hooks or policies."
}
+}
+
+run "empty_runner_specs_use_release_artifact_fallback" {
+ command = plan
+ variables {
+ runner_configs = {
+ env = "test"
+ prefix = "forge-test-empty"
+ ghes_url = ""
+ log_level = "info"
+ logging_retention_in_days = "3"
+ github_app = {
+ key_base64 = "dGVzdA=="
+ id = "12345"
+ webhook_secret = "test"
+ }
+ runner_iam_role_managed_policy_arns = []
+ runner_specs = {}
+ }
+ }
+
+ plan_options {
+ target = [
+ data.external.download_lambdas,
+ ]
+ }
+
+ assert {
+ condition = (
+ length(local.runner_configs) == 0
+ && length(local.ec2_runner_configs) == 0
+ && length(data.external.download_lambdas) == 1
+ && length(module.ec2_update_runner_ssm_ami) == 0
+ && length(module.ec2_update_runner_tags) == 0
+ )
+ error_message = "An empty runner map must skip provider-owned helpers while retaining the release-artifact fallback."
+ }
+
+ assert {
+ condition = (
+ data.external.download_lambdas[0].result.path == "/private/tmp/forge-test-lambda-cache"
+ && "${data.external.download_lambdas[0].result.path}/runners.zip" == "/private/tmp/forge-test-lambda-cache/runners.zip"
+ && "${data.external.download_lambdas[0].result.path}/webhook.zip" == "/private/tmp/forge-test-lambda-cache/webhook.zip"
+ && "${data.external.download_lambdas[0].result.path}/runner-binaries-syncer.zip" == "/private/tmp/forge-test-lambda-cache/runner-binaries-syncer.zip"
+ )
+ error_message = "With no runner lanes or caller artifacts, Forge must preserve the v7.10.1 control-plane, webhook, and syncer fallback paths."
+ }
}
diff --git a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl
index 7534951f5..cbd11eb22 100644
--- a/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl
+++ b/modules/platform/ec2_deployment/tests/interface_contract.tftest.hcl
@@ -18,6 +18,11 @@ run "platform_ec2_deployment_interface_contract" {
"ec2_runners_arn_map",
"ec2_runners_labels_map",
"event_bus_name",
+ "microvm_runners_arn_map",
+ "microvm_runners_labels_map",
+ "microvm_runners_map",
+ "runners_arn_map",
+ "runners_labels_map",
"subnet_cidr_blocks",
"webhook_endpoint",
]
@@ -42,6 +47,9 @@ run "platform_ec2_deployment_interface_contract" {
"id = string",
"webhook_secret = string",
"runner_iam_role_managed_policy_arns = list(string)",
+ "lambda_artifacts = optional(object({",
+ "control_plane_zip = optional(string, null)",
+ "webhook_zip = optional(string, null)",
"runner_specs = map(object({",
"tags = optional(map(string), {})",
"runner = object({",
@@ -226,6 +234,19 @@ run "platform_ec2_deployment_interface_contract" {
"log_stream_name = string",
"log_class = optional(string, \"STANDARD\")",
"tags = optional(map(string), {})",
+ "microvm = optional(object({",
+ "image_arn = optional(string, null)",
+ "image_version = optional(string, null)",
+ "ingress_network_connectors = optional(list(string), null)",
+ "egress_network_connectors = optional(list(string), null)",
+ "maximum_duration_in_seconds = optional(number, null)",
+ "environment_variables = optional(map(string), {})",
+ "resource_arns = optional(object({",
+ "images = optional(list(string), null)",
+ "microvms = optional(list(string), null)",
+ "additional_policy_json = optional(object({",
+ "scale_up = optional(string, null)",
+ "managed_policies = optional(object({",
"matcherConfig = object({",
"labelMatchers = list(list(string))",
"exactMatch = optional(bool, false)",
@@ -238,10 +259,31 @@ run "platform_ec2_deployment_interface_contract" {
"for provider_config in values(runner_config.orchestration_provider) : provider_config",
"if provider_config != null",
"error_message = \"Each Forge runner configuration must select exactly one non-null orchestration provider.\"",
+ "for provider_namespace, provider_configs in runner_config.compute_provider : [",
+ "for provider_type, provider_config in provider_configs :",
+ "error_message = \"Each Forge runner configuration must select exactly one non-null compute-provider block. Supported compute-provider blocks: aws.ec2, aws.microvm.\"",
+ "runner_config.compute_provider.aws.ec2 == null ? true : try(",
+ "runner_config.compute_provider.aws.microvm == null ? true : (",
+ "runner_config.runner.os == \"linux\" && runner_config.runner.architecture == \"arm64\"",
+ "error_message = \"Forge Lambda MicroVM runner_specs require runner.os = linux and runner.architecture = arm64.\"",
+ "error_message = \"Forge Lambda MicroVM runner_specs require webhook orchestration with ephemeral and JIT runner configuration enabled.\"",
+ "^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$",
+ "error_message = \"Forge Lambda MicroVM runner_specs must configure a valid compute_provider.aws.microvm.image_arn.\"",
+ "try(trimspace(var.runner_configs.lambda_artifacts.control_plane_zip), \"\") != \"\"",
+ "&& try(trimspace(var.runner_configs.lambda_artifacts.webhook_zip), \"\") != \"\"",
+ "error_message = \"Forge Lambda MicroVM runner_specs require lambda_artifacts.control_plane_zip and lambda_artifacts.webhook_zip built from the selected upstream MicroVM branch.\"",
+ "Runner map keys, selected compute-provider wrapper presence, runner.os,",
+ "runner.architecture, compute_provider.aws.ec2.binaries_syncer.enabled, and",
+ "provider-scoped subnet IDs must be known during planning because they determine",
+ "module and resource topology.",
"variable \"tenant_configs\"",
"ecr_registries = list(string)",
"tags = map(string)",
- "ec2_runner_configs = var.runner_configs.runner_specs",
+ "runner_configs = var.runner_configs.runner_specs",
+ "ec2_runner_configs = {",
+ "microvm_runner_configs = {",
+ "if length(try(runner_config.compute_provider.aws.ec2[*], [])) == 1",
+ "if length(try(runner_config.compute_provider.aws.microvm[*], [])) == 1",
"effective_runner_users = {",
"effective_runner_home_directories = {",
"forge_runner_hook_job_started = {",
@@ -249,7 +291,6 @@ run "platform_ec2_deployment_interface_contract" {
"effective_runner_hooks = {",
"textencodebase64(local.effective_runner_hooks[key].job_started, \"UTF-16LE\")",
"base64encode(local.effective_runner_hooks[key].job_started)",
- "length(runner_config.compute_provider.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.\"",
@@ -269,37 +310,46 @@ run "platform_ec2_deployment_interface_contract" {
"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",
+ "count = length(local.ec2_runner_configs) > 0 ? 1 : 0",
"key => merge(",
"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)",
+ "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 = merge(local.ec2_compute_provider[key], {",
+ "runner_iam_policy_arn_prefix = \"arn:$${data.aws_partition.current.partition}:iam::$${data.aws_caller_identity.current.account_id}:policy\"",
+ "forge_ec2_tags = \"$${local.runner_iam_policy_arn_prefix}$${aws_iam_policy.ec2_tags[0].path}$${aws_iam_policy.ec2_tags[0].name}\"",
+ "forge_runner_hooks_ssm_read = \"$${local.runner_iam_policy_arn_prefix}$${aws_iam_policy.runner_hooks_ssm_read[0].path}$${aws_iam_policy.runner_hooks_ssm_read[0].name}\"",
+ "count = length(local.ec2_runner_configs) > 0 ? 1 : 0",
+ "aws = merge(runner_config.compute_provider.aws, {",
+ "ec2 = try(local.ec2_compute_provider[key], null)",
+ "control_plane_lambda_zip = try(coalesce(",
+ "try(var.runner_configs.lambda_artifacts.control_plane_zip, null)",
+ "try(\"$${data.external.download_lambdas[0].result.path}/runners.zip\", null)",
+ "webhook_lambda_zip = try(coalesce(",
+ "try(var.runner_configs.lambda_artifacts.webhook_zip, null)",
+ "try(\"$${data.external.download_lambdas[0].result.path}/webhook.zip\", null)",
"runner_home = local.effective_runner_home_directories[key]",
"job_started = local.runner_hook_job_started[key]",
"runner_ssm_paths = {",
"runner_ami_ssm_parameter_names = {",
"root = \"$${trimsuffix(coalesce(runner_config.ssm.paths.root, \"/github-action-runners/$${var.runner_configs.prefix}\"), \"/\")}/$${key}\"",
- "multi_runner_config = {}",
"vpc_id = var.network_configs.vpc_id",
"subnet_ids = var.network_configs.subnet_ids",
"github_app = var.runner_configs.github_app",
- "experimental_global_config = {",
- "experimental_global_config_github = local.experimental_config.github",
- "experimental_global_config_lambda = local.experimental_config.lambda",
- "experimental_global_config_orchestration_provider = local.experimental_config.orchestration_provider",
- "experimental_global_config_ssm = local.experimental_config.ssm",
- "experimental_global_config_observability = local.experimental_config.observability",
- "experimental_global_config_compute_provider = local.experimental_config.compute_provider",
- "experimental_multi_runner_config = local.multi_runner_config",
- "experimental_config = {",
+ "experimental_features = [\"multi-runner-v2\"]",
+ "global_config = {",
+ "global_config_github = local.global_config.github",
+ "global_config_lambda = local.global_config.lambda",
+ "global_config_orchestration_provider = local.global_config.orchestration_provider",
+ "global_config_ssm = local.global_config.ssm",
+ "global_config_observability = local.global_config.observability",
+ "global_config_compute_provider = local.global_config.compute_provider",
+ "multi_runner_config = local.multi_runner_config",
"app = var.runner_configs.github_app",
"enterprise_server = {",
"orchestration_provider = {",
@@ -316,12 +366,28 @@ run "platform_ec2_deployment_interface_contract" {
"output \"ec2_runners_ami_name_map\"",
"data.aws_ami.runner_ami[runner_key].name",
"output \"ec2_runners_labels_map\"",
+ "output \"runners_arn_map\"",
+ "output \"runners_labels_map\"",
+ "output \"microvm_runners_arn_map\"",
+ "output \"microvm_runners_labels_map\"",
+ "output \"microvm_runners_map\"",
"output \"event_bus_name\"",
"value = module.runners.webhook.eventbridge.event_bus.name",
"output \"subnet_cidr_blocks\"",
"value = { for id, subnet in data.aws_subnet.runner_subnet : id => subnet.cidr_block }",
"output \"webhook_endpoint\"",
"value = module.runners.webhook.endpoint",
+ "program = [\"bash\", \"$${path.module}/scripts/download_lambdas.sh\", \"/tmp/$${var.runner_configs.prefix}/v7.10.1/\", \"v7.10.1\", \"github-aws-runners/terraform-aws-github-runner\"]",
+ "|| try(var.runner_configs.lambda_artifacts.control_plane_zip, null) == null",
+ "|| try(var.runner_configs.lambda_artifacts.webhook_zip, null) == null",
+ "from = aws_iam_policy.ec2_tags",
+ "to = aws_iam_policy.ec2_tags[0]",
+ "from = aws_iam_policy.runner_hooks_ssm_read",
+ "to = aws_iam_policy.runner_hooks_ssm_read[0]",
+ "from = module.ec2_update_runner_ssm_ami",
+ "to = module.ec2_update_runner_ssm_ami[0]",
+ "from = module.ec2_update_runner_tags",
+ "to = module.ec2_update_runner_tags[0]",
]
}
@@ -353,8 +419,8 @@ run "platform_ec2_deployment_interface_contract" {
assert {
condition = (
output.expected_input_variable_count == 4
- && output.expected_output_value_count == 6
- && output.expected_interface_literal_count == 300
+ && output.expected_output_value_count == 11
+ && output.expected_interface_literal_count == 361
)
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 6eccd4f6b..30b03c9b0 100644
--- a/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl
+++ b/modules/platform/ec2_deployment/tests/source_inventory.tftest.hcl
@@ -10,26 +10,49 @@ run "platform_ec2_deployment_contract" {
expected_literals = [
"module \"ec2_update_runner_ssm_ami\"",
"module \"ec2_update_runner_tags\"",
+ "count = length(local.ec2_runner_configs) > 0 ? 1 : 0",
"module \"runners\"",
"source = \"git::https://github.com/github-aws-runners/terraform-aws-github-runner.git//modules/multi-runner?ref=fix-multi-runner-v2-routing\"",
"vpc_id = var.network_configs.vpc_id",
"subnet_ids = var.network_configs.subnet_ids",
"github_app = var.runner_configs.github_app",
- "multi_runner_config = {}",
- "experimental_global_config = {",
- "experimental_global_config_github = local.experimental_config.github",
- "experimental_global_config_lambda = local.experimental_config.lambda",
- "experimental_global_config_orchestration_provider = local.experimental_config.orchestration_provider",
- "experimental_global_config_ssm = local.experimental_config.ssm",
- "experimental_global_config_observability = local.experimental_config.observability",
- "experimental_global_config_compute_provider = local.experimental_config.compute_provider",
- "experimental_multi_runner_config = local.multi_runner_config",
- "experimental_config = {",
+ "experimental_features = [\"multi-runner-v2\"]",
+ "global_config = {",
+ "global_config_github = local.global_config.github",
+ "global_config_lambda = local.global_config.lambda",
+ "global_config_orchestration_provider = local.global_config.orchestration_provider",
+ "global_config_ssm = local.global_config.ssm",
+ "global_config_observability = local.global_config.observability",
+ "global_config_compute_provider = local.global_config.compute_provider",
+ "multi_runner_config = local.multi_runner_config",
"app = var.runner_configs.github_app",
"enterprise_server = {",
"orchestration_provider = {",
+ "runner_configs = var.runner_configs.runner_specs",
+ "runner_iam_policy_arn_prefix = \"arn:$${data.aws_partition.current.partition}:iam::$${data.aws_caller_identity.current.account_id}:policy\"",
+ "runner_binaries_targets = {",
+ "for target in distinct([",
+ "length(try(runner_config.compute_provider.aws.ec2[*], [])) == 1",
+ "try(runner_config.compute_provider.aws.ec2.binaries_syncer.enabled, null)",
+ "\"$${target.os}_$${target.architecture}\" => target",
+ "compute_provider_selections = {",
+ "namespace = \"aws\"",
+ "length(try(runner_config.compute_provider.aws.ec2[*], [])) == 1 ? \"ec2\" :",
+ "length(try(runner_config.compute_provider.aws.microvm[*], [])) == 1 ? \"microvm\" : null",
+ "ec2_runner_configs = {",
+ "microvm_runner_configs = {",
+ "if length(try(runner_config.compute_provider.aws.ec2[*], [])) == 1",
+ "if length(try(runner_config.compute_provider.aws.microvm[*], [])) == 1",
"runner_config.compute_provider.aws.ec2,",
- "ec2 = merge(local.ec2_compute_provider[key], {",
+ "aws = merge(runner_config.compute_provider.aws, {",
+ "ec2 = try(local.ec2_compute_provider[key], null)",
+ "microvm = optional(object({",
+ "image_arn = optional(string, null)",
+ "lambda_artifacts = optional(object({",
+ "control_plane_zip = optional(string, null)",
+ "webhook_zip = optional(string, null)",
+ "control_plane_lambda_zip = try(coalesce(",
+ "webhook_lambda_zip = try(coalesce(",
"boot_time_in_minutes = optional(number, null)",
"ephemeral = optional(bool, null)",
"jit_config_enabled = optional(bool, null)",
@@ -40,6 +63,8 @@ run "platform_ec2_deployment_contract" {
"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",
+ "selections = local.compute_provider_selections",
+ "targets = local.runner_binaries_targets",
"multi_runner_config = local.multi_runner_config",
"resource \"aws_cloudwatch_log_group\" \"webhook_api_gateway_access\"",
"resource \"aws_kms_key\" \"github\"",
@@ -52,6 +77,9 @@ run "platform_ec2_deployment_contract" {
"key => \"$${paths.root}/$${paths.config}/ami_id\"",
"resource \"aws_iam_policy\" \"runner_hooks_ssm_read\"",
"resource \"aws_iam_policy\" \"ec2_tags\"",
+ "forge_ec2_tags = \"$${local.runner_iam_policy_arn_prefix}$${aws_iam_policy.ec2_tags[0].path}$${aws_iam_policy.ec2_tags[0].name}\"",
+ "forge_runner_hooks_ssm_read = \"$${local.runner_iam_policy_arn_prefix}$${aws_iam_policy.runner_hooks_ssm_read[0].path}$${aws_iam_policy.runner_hooks_ssm_read[0].name}\"",
+ "count = length(local.ec2_runner_configs) > 0 ? 1 : 0",
"resource \"aws_security_group\" \"gh_runner_lambda_egress\"",
"data \"aws_ssm_parameter\" \"ami_id\"",
"data \"aws_ami\" \"runner_ami\"",
@@ -59,14 +87,31 @@ run "platform_ec2_deployment_contract" {
"data \"aws_partition\" \"current\"",
"data \"aws_subnet\" \"runner_subnet\"",
"data \"external\" \"download_lambdas\"",
+ "program = [\"bash\", \"$${path.module}/scripts/download_lambdas.sh\", \"/tmp/$${var.runner_configs.prefix}/v7.10.1/\", \"v7.10.1\", \"github-aws-runners/terraform-aws-github-runner\"]",
+ "|| try(var.runner_configs.lambda_artifacts.control_plane_zip, null) == null",
+ "|| try(var.runner_configs.lambda_artifacts.webhook_zip, null) == null",
+ "data.external.download_lambdas[0].result.path",
"data \"aws_iam_policy_document\" \"runner_hooks_ssm_read\"",
"data \"aws_iam_policy_document\" \"ec2_tags\"",
"output \"webhook_endpoint\"",
"output \"ec2_runners_arn_map\"",
"output \"ec2_runners_ami_name_map\"",
"output \"ec2_runners_labels_map\"",
+ "output \"runners_arn_map\"",
+ "output \"runners_labels_map\"",
+ "output \"microvm_runners_arn_map\"",
+ "output \"microvm_runners_labels_map\"",
+ "output \"microvm_runners_map\"",
"output \"subnet_cidr_blocks\"",
"output \"event_bus_name\"",
+ "from = aws_iam_policy.ec2_tags",
+ "to = aws_iam_policy.ec2_tags[0]",
+ "from = aws_iam_policy.runner_hooks_ssm_read",
+ "to = aws_iam_policy.runner_hooks_ssm_read[0]",
+ "from = module.ec2_update_runner_ssm_ami",
+ "to = module.ec2_update_runner_ssm_ami[0]",
+ "from = module.ec2_update_runner_tags",
+ "to = module.ec2_update_runner_tags[0]",
]
}
diff --git a/modules/platform/ec2_deployment/variables.tf b/modules/platform/ec2_deployment/variables.tf
index 1979a714c..f23d701b3 100644
--- a/modules/platform/ec2_deployment/variables.tf
+++ b/modules/platform/ec2_deployment/variables.tf
@@ -16,6 +16,10 @@ variable "runner_configs" {
webhook_secret = string
})
runner_iam_role_managed_policy_arns = list(string)
+ lambda_artifacts = optional(object({
+ control_plane_zip = optional(string, null)
+ webhook_zip = optional(string, null)
+ }), {})
runner_specs = map(object({
tags = optional(map(string), {})
@@ -345,6 +349,31 @@ variable "runner_configs" {
})), null)
tags = optional(map(string), {})
}), null)
+ microvm = optional(object({
+ image_arn = optional(string, null)
+ image_version = optional(string, null)
+ ingress_network_connectors = optional(list(string), null)
+ egress_network_connectors = optional(list(string), null)
+ maximum_duration_in_seconds = optional(number, null)
+ environment_variables = optional(map(string), {})
+ iam = optional(object({
+ resource_arns = optional(object({
+ images = optional(list(string), null)
+ microvms = optional(list(string), null)
+ }), {})
+ additional_policy_json = optional(object({
+ scale_up = optional(string, null)
+ }), {})
+ managed_policies = optional(object({
+ scale_up = optional(object({
+ arn = string
+ }), null)
+ pool = optional(object({
+ arn = string
+ }), null)
+ }), {})
+ }), {})
+ }), null)
}), {})
})
@@ -365,9 +394,22 @@ variable "runner_configs" {
validation {
condition = alltrue([
for runner_config in values(var.runner_configs.runner_specs) :
- try(
- length(runner_config.compute_provider.aws.ec2[*]) == 1
- && length(runner_config.compute_provider.aws.ec2.ami[*]) == 1
+ length(flatten([
+ for provider_namespace, provider_configs in runner_config.compute_provider : [
+ for provider_type, provider_config in provider_configs :
+ "${provider_namespace}.${provider_type}"
+ if provider_config != null
+ ]
+ ])) == 1
+ ])
+ error_message = "Each Forge runner configuration must select exactly one non-null compute-provider block. Supported compute-provider blocks: aws.ec2, aws.microvm."
+ }
+
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.runner_configs.runner_specs) :
+ runner_config.compute_provider.aws.ec2 == null ? true : try(
+ length(runner_config.compute_provider.aws.ec2.ami[*]) == 1
&& length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0,
false,
)
@@ -375,6 +417,70 @@ variable "runner_configs" {
error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported."
}
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.runner_configs.runner_specs) :
+ runner_config.compute_provider.aws.microvm == null ? true : (
+ runner_config.runner.os == "linux" && runner_config.runner.architecture == "arm64"
+ )
+ ])
+ error_message = "Forge Lambda MicroVM runner_specs require runner.os = linux and runner.architecture = arm64."
+ }
+
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.runner_configs.runner_specs) :
+ runner_config.compute_provider.aws.microvm == null ? true : try(
+ runner_config.orchestration_provider.webhook != null
+ && coalesce(runner_config.orchestration_provider.webhook.runner.ephemeral, false)
+ && coalesce(
+ runner_config.orchestration_provider.webhook.runner.jit_config_enabled,
+ runner_config.orchestration_provider.webhook.runner.ephemeral,
+ false,
+ ),
+ false,
+ )
+ ])
+ error_message = "Forge Lambda MicroVM runner_specs require webhook orchestration with ephemeral and JIT runner configuration enabled."
+ }
+
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.runner_configs.runner_specs) :
+ runner_config.compute_provider.aws.microvm == null ? true : try(
+ can(regex(
+ "^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$",
+ runner_config.compute_provider.aws.microvm.image_arn,
+ )),
+ false,
+ )
+ ])
+ error_message = "Forge Lambda MicroVM runner_specs must configure a valid compute_provider.aws.microvm.image_arn."
+ }
+
+ validation {
+ condition = !anytrue([
+ for runner_config in values(var.runner_configs.runner_specs) :
+ runner_config.compute_provider.aws.microvm != null
+ ]) || (
+ try(trimspace(var.runner_configs.lambda_artifacts.control_plane_zip), "") != ""
+ && try(trimspace(var.runner_configs.lambda_artifacts.webhook_zip), "") != ""
+ )
+ error_message = "Forge Lambda MicroVM runner_specs require lambda_artifacts.control_plane_zip and lambda_artifacts.webhook_zip built from the selected upstream MicroVM branch."
+ }
+
+ description = <<-EOT
+ Forge runner deployment configuration. lambda_artifacts.control_plane_zip and
+ lambda_artifacts.webhook_zip select local Lambda archives; MicroVM lanes require
+ both archives to be built from the selected upstream MicroVM branch. The upstream
+ provider creates each MicroVM runtime log group from the common observability
+ settings, so callers do not select a log-group name.
+
+ Runner map keys, selected compute-provider wrapper presence, runner.os,
+ runner.architecture, compute_provider.aws.ec2.binaries_syncer.enabled, and
+ provider-scoped subnet IDs must be known during planning because they determine
+ module and resource topology.
+ EOT
}
variable "network_configs" {
diff --git a/modules/platform/forge_runners/README.md b/modules/platform/forge_runners/README.md
index 201b315da..c5e28430c 100644
--- a/modules/platform/forge_runners/README.md
+++ b/modules/platform/forge_runners/README.md
@@ -4,11 +4,11 @@ This is the umbrella tenant module for Forge GitHub Actions runners.
## Why This Module Exists
-Forge is a multi-tenant CI platform built around ephemeral runners, short-lived identity, and centralized operations. This module composes the tenant stack: EC2 runners, ARC runners, GitHub App settings, runner group reconciliation, trust validation, log archival, webhook relay, and self-healing support utilities.
+Forge is a multi-tenant CI platform built around ephemeral runners, short-lived identity, and centralized operations. This module composes the tenant stack: EC2 runners, Lambda MicroVM runners, ARC runners, GitHub App settings, runner group reconciliation, trust validation, log archival, webhook relay, and self-healing support utilities.
## What It Manages
-- EC2 and ARC runner deployment.
+- EC2, Lambda MicroVM, and ARC runner deployment.
- GitHub App secret material in SSM Parameter Store.
- IAM policies that let runners assume tenant-approved roles and pull allowed ECR images.
- Runner group registration, trust validation, global lock, job log archival, webhook relay, and DLQ redrive helpers.
@@ -18,14 +18,20 @@ 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 runner configurations can be enabled for the same tenant; workflows choose by labels.
+- EC2, Lambda MicroVM, and ARC runner configurations can be enabled for the same tenant; workflows choose by labels.
- `ec2_deployment_specs.runner_specs` uses the full nested v2 shape; every entry
- must configure `runner`, `orchestration_provider.webhook`, and `compute_provider.aws.ec2`.
+ must configure `runner`, `orchestration_provider.webhook`, and exactly one of
+ `compute_provider.aws.ec2` or `compute_provider.aws.microvm`. The
+ `ec2_deployment_specs` name remains for compatibility while the contained map
+ is provider-aware.
- `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.
+- Forge applies AMI, instance bootstrap, lifecycle-hook, tag-policy, and helper
+ Lambda behavior only to EC2 lanes. Lambda MicroVM lanes retain their
+ caller-provided provider configuration and hooks.
- 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
@@ -33,6 +39,19 @@ Forge is a multi-tenant CI platform built around ephemeral runners, short-lived
- The shared runner-control archive is selected globally at
`orchestration_provider.webhook.lambda.artifact`; per-runner webhook Lambda blocks do
not select deployment artifacts.
+- Lambda MicroVM lanes require both
+ `ec2_deployment_specs.lambda_artifacts.control_plane_zip` and
+ `ec2_deployment_specs.lambda_artifacts.webhook_zip`, built from the same
+ reviewed `fix-multi-runner-v2-routing` revision as the Terraform source.
+ EC2-only deployments retain the v7.10.1 release-artifact fallback.
+- `forge_runners.ec2` remains unchanged for existing consumers.
+ `forge_runners.microvm` exposes MicroVM execution-role ARNs, provider-owned
+ image and runtime log-group metadata, and runner labels. Trust validation
+ consumes the canonical all-compute-provider role map.
+- The upstream MicroVM provider creates one runtime CloudWatch log group per
+ runner configuration. Its retention, encryption, class, and tags come from
+ the existing `observability.logs` settings; callers do not pass a log-group
+ name in `compute_provider.aws.microvm`.
- 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
@@ -121,6 +140,7 @@ tag with the expected ref in `release_versions.yml`.
| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
| [aws_iam_policy_document.ecr_access_for_ec2_instances](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
| [aws_iam_policy_document.role_assumption_for_forge_runners](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source |
| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source |
| [aws_ssm_parameter.github_app_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ssm_parameter) | data source |
@@ -133,7 +153,7 @@ tag with the expected ref in `release_versions.yml`.
| [aws\_region](#input\_aws\_region) | AWS region where Forge runners and supporting infrastructure are deployed. | `string` | n/a | yes |
| [default\_tags](#input\_default\_tags) | A map of tags to apply to resources. | `map(string)` | n/a | yes |
| [deployment\_config](#input\_deployment\_config) | High-level deployment configuration for a Forge runner installation.object({
deployment_prefix = string
secret_suffix = string
env = string
github_app = object({
id = string
client_id = string
installation_id = string
name = string
})
github = object({
ghes_org = string
ghes_url = string
repository_selection = string
runner_group_name = string
})
tenant = object({
name = string
iam_roles_to_assume = optional(list(string), [])
ecr_registries = optional(list(string), [])
github_logs_reader_role_arns = optional(list(string), [])
})
}) | n/a | yes |
-| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | EC2 deployment configuration for GitHub Actions runners. The public runnerobject({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
runner_specs = map(object({
tags = optional(map(string), {})
runner = object({
os = string
architecture = string
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 = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
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)
dynamic_labels_enabled = optional(bool, null)
# Deprecated compatibility alias; normalize it to the upstream
# dynamic_labels_enabled field before invoking the runner module.
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({
enabled = optional(bool, null)
enable = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
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)
on_demand_failover_for_errors = optional(list(string), null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
}), {})
})
}))
}) | n/a | yes |
+| [ec2\_deployment\_specs](#input\_ec2\_deployment\_specs) | Runner deployment configuration for GitHub Actions runners. The public runnerobject({
lambda_subnet_ids = list(string)
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
lambda_artifacts = optional(object({
control_plane_zip = optional(string, null)
webhook_zip = optional(string, null)
}), {})
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)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
maximum_duration_in_seconds = optional(number, null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
microvms = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
})
}))
}) | n/a | yes |
| [github\_webhook\_relay](#input\_github\_webhook\_relay) | Configuration for the (optional) webhook relay source module.object({
enabled = bool
destination_account_id = optional(string)
destination_event_bus_name = optional(string)
destination_region = optional(string)
destination_reader_role_arn = optional(string)
}) | {
"destination_account_id": "",
"destination_event_bus_name": "",
"destination_reader_role_arn": "",
"destination_region": "",
"enabled": false
} | no |
| [log\_level](#input\_log\_level) | Log level for application logging (e.g., INFO, DEBUG, WARN, ERROR) | `string` | n/a | yes |
| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Logging retention period in days. | `string` | n/a | yes |
@@ -146,6 +166,6 @@ tag with the expected ref in `release_versions.yml`.
| [forge\_core](#output\_forge\_core) | Core tenant-level metadata (non-sensitive). |
| [forge\_github\_actions\_job\_logs](#output\_forge\_github\_actions\_job\_logs) | GitHub Actions job log archival resources. |
| [forge\_github\_app](#output\_forge\_github\_app) | GitHub App related outputs. |
-| [forge\_runners](#output\_forge\_runners) | Combined runners output (EC2 + ARC) |
+| [forge\_runners](#output\_forge\_runners) | Combined runners output (EC2 + Lambda MicroVM + ARC) |
| [forge\_webhook\_relay](#output\_forge\_webhook\_relay) | Webhook relay integration outputs. |
diff --git a/modules/platform/forge_runners/data.tf b/modules/platform/forge_runners/data.tf
index f4693af5f..99b50de05 100644
--- a/modules/platform/forge_runners/data.tf
+++ b/modules/platform/forge_runners/data.tf
@@ -1,3 +1,5 @@
data "aws_caller_identity" "current" {}
+data "aws_partition" "current" {}
+
data "aws_region" "current" {}
diff --git a/modules/platform/forge_runners/ec2_runners.tf b/modules/platform/forge_runners/ec2_runners.tf
index e4866315a..4de53d380 100644
--- a/modules/platform/forge_runners/ec2_runners.tf
+++ b/modules/platform/forge_runners/ec2_runners.tf
@@ -36,6 +36,7 @@ module "ec2_runners" {
log_level = var.log_level
logging_retention_in_days = var.logging_retention_in_days
runner_iam_role_managed_policy_arns = local.runner_iam_role_managed_policy_arns
+ lambda_artifacts = var.ec2_deployment_specs.lambda_artifacts
github_app = {
key_base64 = data.aws_ssm_parameter.github_app_key.value
id = var.deployment_config.github_app.id
diff --git a/modules/platform/forge_runners/forge_trust_validator.tf b/modules/platform/forge_runners/forge_trust_validator.tf
index beb77721c..99314c55d 100644
--- a/modules/platform/forge_runners/forge_trust_validator.tf
+++ b/modules/platform/forge_runners/forge_trust_validator.tf
@@ -13,7 +13,7 @@ module "forge_trust_validator" {
forge_iam_roles = {
for idx, arn in values(merge(
- try(module.ec2_runners[0].ec2_runners_arn_map, {}),
+ try(module.ec2_runners[0].runners_arn_map, {}),
try(module.arc_runners.arc_runners_arn_map, {}),
)) :
idx => arn
diff --git a/modules/platform/forge_runners/github_global_lock/README.md b/modules/platform/forge_runners/github_global_lock/README.md
index 745f3692c..3436388eb 100644
--- a/modules/platform/forge_runners/github_global_lock/README.md
+++ b/modules/platform/forge_runners/github_global_lock/README.md
@@ -49,8 +49,10 @@ Some CI/CD operations are safe only when serialized across repositories or workf
| [aws_dynamodb_table.lock_table](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table) | resource |
| [aws_iam_policy.dynamodb_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource |
| [aws_lambda_permission.clean_global_lock_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource |
+| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
| [aws_iam_policy_document.clean_global_lock_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
| [aws_iam_policy_document.dynamodb_policy_document](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source |
| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source |
## Inputs
diff --git a/modules/platform/forge_runners/github_global_lock/data.tf b/modules/platform/forge_runners/github_global_lock/data.tf
index 2502393bf..99b50de05 100644
--- a/modules/platform/forge_runners/github_global_lock/data.tf
+++ b/modules/platform/forge_runners/github_global_lock/data.tf
@@ -1 +1,5 @@
+data "aws_caller_identity" "current" {}
+
+data "aws_partition" "current" {}
+
data "aws_region" "current" {}
diff --git a/modules/platform/forge_runners/github_global_lock/main.tf b/modules/platform/forge_runners/github_global_lock/main.tf
index 94c8bad5a..fa42821b8 100644
--- a/modules/platform/forge_runners/github_global_lock/main.tf
+++ b/modules/platform/forge_runners/github_global_lock/main.tf
@@ -70,6 +70,7 @@ data "aws_iam_policy_document" "dynamodb_policy_document" {
resource "aws_iam_policy" "dynamodb_policy" {
name = "${var.prefix}-dynamodb-put-delete-policy"
+ path = "/"
description = "Allow PutItem and DeleteItem actions on DynamoDB table"
policy = data.aws_iam_policy_document.dynamodb_policy_document.json
diff --git a/modules/platform/forge_runners/github_global_lock/outputs.tf b/modules/platform/forge_runners/github_global_lock/outputs.tf
index 9b8cf94e4..e7cee66a6 100644
--- a/modules/platform/forge_runners/github_global_lock/outputs.tf
+++ b/modules/platform/forge_runners/github_global_lock/outputs.tf
@@ -1,4 +1,4 @@
output "dynamodb_policy_arn" {
description = "ARN of the IAM policy granting DynamoDB lock table CRUD access."
- value = aws_iam_policy.dynamodb_policy.arn
+ value = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy${aws_iam_policy.dynamodb_policy.path}${aws_iam_policy.dynamodb_policy.name}"
}
diff --git a/modules/platform/forge_runners/github_global_lock/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/github_global_lock/tests/interface_contract.tftest.hcl
index b845b91b6..348b942e4 100644
--- a/modules/platform/forge_runners/github_global_lock/tests/interface_contract.tftest.hcl
+++ b/modules/platform/forge_runners/github_global_lock/tests/interface_contract.tftest.hcl
@@ -41,7 +41,7 @@ run "platform_forge_runners_github_global_lock_interface_contract" {
"default = {}",
"output \"dynamodb_policy_arn\"",
"description = \"ARN of the IAM policy granting DynamoDB lock table CRUD access.\"",
- "value = aws_iam_policy.dynamodb_policy.arn",
+ "value = \"arn:$${data.aws_partition.current.partition}:iam::$${data.aws_caller_identity.current.account_id}:policy$${aws_iam_policy.dynamodb_policy.path}$${aws_iam_policy.dynamodb_policy.name}\"",
]
}
diff --git a/modules/platform/forge_runners/github_global_lock/tests/source_inventory.tftest.hcl b/modules/platform/forge_runners/github_global_lock/tests/source_inventory.tftest.hcl
index 4945a9b2d..39adcbb39 100644
--- a/modules/platform/forge_runners/github_global_lock/tests/source_inventory.tftest.hcl
+++ b/modules/platform/forge_runners/github_global_lock/tests/source_inventory.tftest.hcl
@@ -15,10 +15,13 @@ run "platform_forge_runners_github_global_lock_contract" {
"resource \"aws_cloudwatch_event_rule\" \"clean_global_lock_lambda\"",
"resource \"aws_cloudwatch_event_target\" \"clean_global_lock_lambda\"",
"resource \"aws_lambda_permission\" \"clean_global_lock_lambda\"",
+ "data \"aws_caller_identity\" \"current\"",
+ "data \"aws_partition\" \"current\"",
"data \"aws_region\" \"current\"",
"data \"aws_iam_policy_document\" \"dynamodb_policy_document\"",
"data \"aws_iam_policy_document\" \"clean_global_lock_lambda\"",
"output \"dynamodb_policy_arn\"",
+ "value = \"arn:$${data.aws_partition.current.partition}:iam::$${data.aws_caller_identity.current.account_id}:policy$${aws_iam_policy.dynamodb_policy.path}$${aws_iam_policy.dynamodb_policy.name}\"",
]
}
diff --git a/modules/platform/forge_runners/locals.tf b/modules/platform/forge_runners/locals.tf
index b7980b2c4..b58c326be 100644
--- a/modules/platform/forge_runners/locals.tf
+++ b/modules/platform/forge_runners/locals.tf
@@ -1,9 +1,12 @@
locals {
+ iam_policy_arn_prefix = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy"
runner_iam_role_managed_policy_arns = concat(
# If the policy exists, include it, otherwise skip it
- length(var.deployment_config.tenant.iam_roles_to_assume) > 0 ? [aws_iam_policy.role_assumption_for_forge_runners[0].arn] : [],
+ length(var.deployment_config.tenant.iam_roles_to_assume) > 0 ? [
+ "${local.iam_policy_arn_prefix}${aws_iam_policy.role_assumption_for_forge_runners[0].path}${aws_iam_policy.role_assumption_for_forge_runners[0].name}"
+ ] : [],
[
- aws_iam_policy.ecr_access_for_ec2_instances.arn,
+ "${local.iam_policy_arn_prefix}${aws_iam_policy.ecr_access_for_ec2_instances.path}${aws_iam_policy.ecr_access_for_ec2_instances.name}",
module.github_global_lock.dynamodb_policy_arn,
]
)
diff --git a/modules/platform/forge_runners/outputs.tf b/modules/platform/forge_runners/outputs.tf
index d03f7b97e..414e32811 100644
--- a/modules/platform/forge_runners/outputs.tf
+++ b/modules/platform/forge_runners/outputs.tf
@@ -7,7 +7,7 @@ output "forge_core" {
}
output "forge_runners" {
- description = "Combined runners output (EC2 + ARC)"
+ description = "Combined runners output (EC2 + Lambda MicroVM + ARC)"
value = {
ec2 = {
runners_arn_map = try(module.ec2_runners[0].ec2_runners_arn_map, {})
@@ -15,6 +15,11 @@ output "forge_runners" {
subnet_cidr_blocks = try(module.ec2_runners[0].subnet_cidr_blocks, [])
runner_labels = try(module.ec2_runners[0].ec2_runners_labels_map, {})
}
+ microvm = {
+ runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {})
+ runners = try(module.ec2_runners[0].microvm_runners_map, {})
+ runner_labels = try(module.ec2_runners[0].microvm_runners_labels_map, {})
+ }
arc = {
cluster_name = try(module.arc_runners.arc_cluster_name, {})
runners_arn_map = try(module.arc_runners.arc_runners_arn_map, {})
diff --git a/modules/platform/forge_runners/roles.tf b/modules/platform/forge_runners/roles.tf
index dabdb2353..745f1ca5d 100644
--- a/modules/platform/forge_runners/roles.tf
+++ b/modules/platform/forge_runners/roles.tf
@@ -17,6 +17,7 @@ resource "aws_iam_policy" "role_assumption_for_forge_runners" {
count = length(var.deployment_config.tenant.iam_roles_to_assume) > 0 ? 1 : 0
name = "${var.deployment_config.deployment_prefix}-policy-for-role-assumption-for-forge_runners"
+ path = "/"
description = "Managed policy for IAM role assumption."
policy = element(data.aws_iam_policy_document.role_assumption_for_forge_runners[*].json, 0)
@@ -45,6 +46,7 @@ data "aws_iam_policy_document" "ecr_access_for_ec2_instances" {
# Define the actual IAM policy for ECR access
resource "aws_iam_policy" "ecr_access_for_ec2_instances" {
name = "${var.deployment_config.deployment_prefix}-policy-for-ecr-access-for-ec2-instances"
+ path = "/"
description = "Managed policy for IAM role assumption."
policy = data.aws_iam_policy_document.ecr_access_for_ec2_instances.json
diff --git a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl
index 6400ea7d4..9eda6bbee 100644
--- a/modules/platform/forge_runners/tests/interface_contract.tftest.hcl
+++ b/modules/platform/forge_runners/tests/interface_contract.tftest.hcl
@@ -144,6 +144,9 @@ run "platform_forge_runners_interface_contract" {
"subnet_ids = list(string)",
"lambda_vpc_id = string",
"vpc_id = string",
+ "lambda_artifacts = optional(object({",
+ "control_plane_zip = optional(string, null)",
+ "webhook_zip = optional(string, null)",
"runner_specs = map(object({",
"tags = optional(map(string), {})",
"runner = object({",
@@ -328,6 +331,19 @@ run "platform_forge_runners_interface_contract" {
"log_stream_name = string",
"log_class = optional(string, \"STANDARD\")",
"tags = optional(map(string), {})",
+ "microvm = optional(object({",
+ "image_arn = optional(string, null)",
+ "image_version = optional(string, null)",
+ "ingress_network_connectors = optional(list(string), null)",
+ "egress_network_connectors = optional(list(string), null)",
+ "maximum_duration_in_seconds = optional(number, null)",
+ "environment_variables = optional(map(string), {})",
+ "resource_arns = optional(object({",
+ "images = optional(list(string), null)",
+ "microvms = optional(list(string), null)",
+ "additional_policy_json = optional(object({",
+ "scale_up = optional(string, null)",
+ "managed_policies = optional(object({",
"matcherConfig = object({",
"labelMatchers = list(list(string))",
"exactMatch = optional(bool, false)",
@@ -340,20 +356,39 @@ run "platform_forge_runners_interface_contract" {
"for provider_config in values(runner_config.orchestration_provider) : provider_config",
"if provider_config != null",
"error_message = \"Each Forge runner configuration must select exactly one non-null orchestration provider.\"",
- "length(runner_config.compute_provider.aws.ec2[*]) == 1",
+ "for provider_namespace, provider_configs in runner_config.compute_provider : [",
+ "for provider_type, provider_config in provider_configs :",
+ "error_message = \"Each Forge runner configuration must select exactly one non-null compute-provider block. Supported compute-provider blocks: aws.ec2, aws.microvm.\"",
+ "runner_config.compute_provider.aws.ec2 == null ? true : try(",
"length(runner_config.compute_provider.aws.ec2.ami[*]) == 1",
"&& length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0,",
"error_message = \"Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported.\"",
- "EC2 deployment configuration for GitHub Actions runners. The public runner",
+ "runner_config.compute_provider.aws.microvm == null ? true : (",
+ "runner_config.runner.os == \"linux\" && runner_config.runner.architecture == \"arm64\"",
+ "error_message = \"Forge Lambda MicroVM runner_specs require runner.os = linux and runner.architecture = arm64.\"",
+ "error_message = \"Forge Lambda MicroVM runner_specs require webhook orchestration with ephemeral and JIT runner configuration enabled.\"",
+ "^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$",
+ "error_message = \"Forge Lambda MicroVM runner_specs must configure a valid compute_provider.aws.microvm.image_arn.\"",
+ "try(trimspace(var.ec2_deployment_specs.lambda_artifacts.control_plane_zip), \"\") != \"\"",
+ "&& try(trimspace(var.ec2_deployment_specs.lambda_artifacts.webhook_zip), \"\") != \"\"",
+ "error_message = \"Forge Lambda MicroVM runner_specs require lambda_artifacts.control_plane_zip and lambda_artifacts.webhook_zip built from the selected upstream MicroVM branch.\"",
+ "Runner 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 configurations.",
+ "- lambda_artifacts : Optional control-plane and webhook Lambda ZIPs.",
+ "MicroVM lanes require both ZIPs to be built from the",
+ "selected upstream MicroVM branch.",
+ "- runner_specs : Map of 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.",
+ "shape follows the nested experimental multi_runner_config contract and is",
+ "passed directly to the upstream provider-oriented runner configuration.",
+ "Runner map keys, selected compute-provider wrapper presence, runner.os,",
+ "runner.architecture, compute_provider.aws.ec2.binaries_syncer.enabled, and",
+ "provider-scoped subnet IDs must be known during planning because they determine",
+ "module and resource topology.",
"- lambda : Provider-neutral per-configuration Lambda runtime,",
"network, role, and tag overrides.",
"- orchestration_provider : Exactly one demand-controller provider. The webhook",
@@ -363,12 +398,21 @@ run "platform_forge_runners_interface_contract" {
"- 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.",
+ "- compute_provider : Nested v2 compute-provider configuration.",
"- tags : Per-configuration resource tags.",
"compute_provider.aws.ec2 fields:",
"- ami : Upstream-compatible EC2 AMI configuration.",
"- instance_profile: Optional externally managed instance profile. It requires",
"supplies Forge's EC2-tag and hook-SSM permissions.",
+ "compute_provider.aws.microvm fields:",
+ "- image_arn/image_version: Lambda MicroVM image and optional version.",
+ "- ingress_network_connectors/egress_network_connectors: Up to 10 Lambda",
+ "network-connector ARNs in each direction.",
+ "- runtime logs: The upstream MicroVM provider creates and manages a log group",
+ "for each runner configuration from the common observability settings.",
+ "- maximum_duration_in_seconds: Optional integer lifetime from 1 through 28,800 seconds.",
+ "- environment_variables: Provider-specific environment variables.",
+ "- iam: Image and MicroVM resource ARNs plus optional scale-up and pool policies.",
"variable \"github_webhook_relay\"",
"Configuration for the (optional) webhook relay source module.",
"If enabled=true we provision the API Gateway + source EventBridge forwarding rule.",
@@ -407,12 +451,16 @@ run "platform_forge_runners_interface_contract" {
"name = var.deployment_config.github_app.name",
"sensitive = true",
"output \"forge_runners\"",
- "description = \"Combined runners output (EC2 + ARC)\"",
+ "description = \"Combined runners output (EC2 + Lambda MicroVM + ARC)\"",
"ec2 = {",
"runners_arn_map = try(module.ec2_runners[0].ec2_runners_arn_map, {})",
"ami_name_map = try(module.ec2_runners[0].ec2_runners_ami_name_map, {})",
"subnet_cidr_blocks = try(module.ec2_runners[0].subnet_cidr_blocks, [])",
"runner_labels = try(module.ec2_runners[0].ec2_runners_labels_map, {})",
+ "microvm = {",
+ "runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {})",
+ "runners = try(module.ec2_runners[0].microvm_runners_map, {})",
+ "runner_labels = try(module.ec2_runners[0].microvm_runners_labels_map, {})",
"arc = {",
"cluster_name = try(module.arc_runners.arc_cluster_name, {})",
"runners_arn_map = try(module.arc_runners.arc_runners_arn_map, {})",
@@ -455,7 +503,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 == 396
+ && output.expected_interface_literal_count == 437
)
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 718882024..4e68a21e1 100644
--- a/modules/platform/forge_runners/tests/source_inventory.tftest.hcl
+++ b/modules/platform/forge_runners/tests/source_inventory.tftest.hcl
@@ -18,7 +18,7 @@ run "platform_forge_runners_contract" {
"module \"redrive_deadletter\"",
"resource \"random_id\" \"random\"",
"runner_specs = var.ec2_deployment_specs.runner_specs",
- "karpenter_node_pool = var.arc_deployment_specs.karpenter_node_pool",
+ "lambda_artifacts = var.ec2_deployment_specs.lambda_artifacts",
"runner = object({",
"orchestration_provider = object({",
"webhook = optional(object({",
@@ -38,9 +38,16 @@ run "platform_forge_runners_contract" {
"compute_provider = object({",
"aws = optional(object({",
"ec2 = optional(object({",
+ "microvm = optional(object({",
+ "image_arn = optional(string, null)",
"matcherConfig = object({",
"scale_errors = optional(list(string), [",
- "try(module.ec2_runners[0].ec2_runners_arn_map, {}),",
+ "try(module.ec2_runners[0].runners_arn_map, {}),",
+ "description = \"Combined runners output (EC2 + Lambda MicroVM + ARC)\"",
+ "microvm = {",
+ "runners_arn_map = try(module.ec2_runners[0].microvm_runners_arn_map, {})",
+ "runners = try(module.ec2_runners[0].microvm_runners_map, {})",
+ "runner_labels = try(module.ec2_runners[0].microvm_runners_labels_map, {})",
"resource \"aws_iam_policy\" \"role_assumption_for_forge_runners\"",
"resource \"aws_iam_policy\" \"ecr_access_for_ec2_instances\"",
"resource \"aws_servicecatalogappregistry_application\" \"forge\"",
@@ -62,10 +69,14 @@ run "platform_forge_runners_contract" {
"resource \"random_password\" \"github_app_webhook_secret\"",
"resource \"null_resource\" \"update_github_app_webhook\"",
"data \"aws_caller_identity\" \"current\"",
+ "data \"aws_partition\" \"current\"",
"data \"aws_region\" \"current\"",
"data \"aws_iam_policy_document\" \"role_assumption_for_forge_runners\"",
"data \"aws_iam_policy_document\" \"ecr_access_for_ec2_instances\"",
"data \"aws_ssm_parameter\" \"github_app_key\"",
+ "iam_policy_arn_prefix = \"arn:$${data.aws_partition.current.partition}:iam::$${data.aws_caller_identity.current.account_id}:policy\"",
+ "\"$${local.iam_policy_arn_prefix}$${aws_iam_policy.role_assumption_for_forge_runners[0].path}$${aws_iam_policy.role_assumption_for_forge_runners[0].name}\"",
+ "\"$${local.iam_policy_arn_prefix}$${aws_iam_policy.ecr_access_for_ec2_instances.path}$${aws_iam_policy.ecr_access_for_ec2_instances.name}\"",
"output \"forge_core\"",
"output \"forge_runners\"",
"output \"forge_webhook_relay\"",
diff --git a/modules/platform/forge_runners/variables.tf b/modules/platform/forge_runners/variables.tf
index 2e289e08f..50a5b6760 100644
--- a/modules/platform/forge_runners/variables.tf
+++ b/modules/platform/forge_runners/variables.tf
@@ -14,6 +14,10 @@ variable "ec2_deployment_specs" {
subnet_ids = list(string)
lambda_vpc_id = string
vpc_id = string
+ lambda_artifacts = optional(object({
+ control_plane_zip = optional(string, null)
+ webhook_zip = optional(string, null)
+ }), {})
runner_specs = map(object({
tags = optional(map(string), {})
@@ -343,6 +347,31 @@ variable "ec2_deployment_specs" {
})), null)
tags = optional(map(string), {})
}), null)
+ microvm = optional(object({
+ image_arn = optional(string, null)
+ image_version = optional(string, null)
+ ingress_network_connectors = optional(list(string), null)
+ egress_network_connectors = optional(list(string), null)
+ maximum_duration_in_seconds = optional(number, null)
+ environment_variables = optional(map(string), {})
+ iam = optional(object({
+ resource_arns = optional(object({
+ images = optional(list(string), null)
+ microvms = optional(list(string), null)
+ }), {})
+ additional_policy_json = optional(object({
+ scale_up = optional(string, null)
+ }), {})
+ managed_policies = optional(object({
+ scale_up = optional(object({
+ arn = string
+ }), null)
+ pool = optional(object({
+ arn = string
+ }), null)
+ }), {})
+ }), {})
+ }), null)
}), {})
})
@@ -363,9 +392,22 @@ variable "ec2_deployment_specs" {
validation {
condition = alltrue([
for runner_config in values(var.ec2_deployment_specs.runner_specs) :
- try(
- length(runner_config.compute_provider.aws.ec2[*]) == 1
- && length(runner_config.compute_provider.aws.ec2.ami[*]) == 1
+ length(flatten([
+ for provider_namespace, provider_configs in runner_config.compute_provider : [
+ for provider_type, provider_config in provider_configs :
+ "${provider_namespace}.${provider_type}"
+ if provider_config != null
+ ]
+ ])) == 1
+ ])
+ error_message = "Each Forge runner configuration must select exactly one non-null compute-provider block. Supported compute-provider blocks: aws.ec2, aws.microvm."
+ }
+
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.ec2_deployment_specs.runner_specs) :
+ runner_config.compute_provider.aws.ec2 == null ? true : try(
+ length(runner_config.compute_provider.aws.ec2.ami[*]) == 1
&& length(runner_config.compute_provider.aws.ec2.ami.id_ssm_parameter[*]) == 0,
false,
)
@@ -373,17 +415,77 @@ variable "ec2_deployment_specs" {
error_message = "Forge EC2 runner_specs must configure a module-managed ami block; ami = null and external ami.id_ssm_parameter ownership are not supported."
}
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.ec2_deployment_specs.runner_specs) :
+ runner_config.compute_provider.aws.microvm == null ? true : (
+ runner_config.runner.os == "linux" && runner_config.runner.architecture == "arm64"
+ )
+ ])
+ error_message = "Forge Lambda MicroVM runner_specs require runner.os = linux and runner.architecture = arm64."
+ }
+
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.ec2_deployment_specs.runner_specs) :
+ runner_config.compute_provider.aws.microvm == null ? true : try(
+ runner_config.orchestration_provider.webhook != null
+ && coalesce(runner_config.orchestration_provider.webhook.runner.ephemeral, false)
+ && coalesce(
+ runner_config.orchestration_provider.webhook.runner.jit_config_enabled,
+ runner_config.orchestration_provider.webhook.runner.ephemeral,
+ false,
+ ),
+ false,
+ )
+ ])
+ error_message = "Forge Lambda MicroVM runner_specs require webhook orchestration with ephemeral and JIT runner configuration enabled."
+ }
+
+ validation {
+ condition = alltrue([
+ for runner_config in values(var.ec2_deployment_specs.runner_specs) :
+ runner_config.compute_provider.aws.microvm == null ? true : try(
+ can(regex(
+ "^arn:[^:]+:lambda:[^:]+:[0-9]{12}:microvm-image:.+$",
+ runner_config.compute_provider.aws.microvm.image_arn,
+ )),
+ false,
+ )
+ ])
+ error_message = "Forge Lambda MicroVM runner_specs must configure a valid compute_provider.aws.microvm.image_arn."
+ }
+
+ validation {
+ condition = !anytrue([
+ for runner_config in values(var.ec2_deployment_specs.runner_specs) :
+ runner_config.compute_provider.aws.microvm != null
+ ]) || (
+ try(trimspace(var.ec2_deployment_specs.lambda_artifacts.control_plane_zip), "") != ""
+ && try(trimspace(var.ec2_deployment_specs.lambda_artifacts.webhook_zip), "") != ""
+ )
+ error_message = "Forge Lambda MicroVM runner_specs require lambda_artifacts.control_plane_zip and lambda_artifacts.webhook_zip built from the selected upstream MicroVM branch."
+ }
+
description = <<-EOT
- 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.
+ Runner deployment configuration for GitHub Actions runners. The public runner
+ shape follows the nested experimental multi_runner_config contract and is
+ passed directly to the upstream provider-oriented runner configuration.
+
+ Runner map keys, selected compute-provider wrapper presence, runner.os,
+ runner.architecture, compute_provider.aws.ec2.binaries_syncer.enabled, and
+ provider-scoped subnet IDs must be known during planning because they determine
+ module and resource topology.
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.
+ - lambda_artifacts : Optional control-plane and webhook Lambda ZIPs.
+ MicroVM lanes require both ZIPs to be built from the
+ selected upstream MicroVM branch.
+ - runner_specs : Map of runner configurations.
runner_specs[*] object fields:
- runner : Provider-neutral OS, architecture, labels, bootstrap,
@@ -397,7 +499,7 @@ variable "ec2_deployment_specs" {
- 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.
+ - compute_provider : Nested v2 compute-provider configuration.
- tags : Per-configuration resource tags.
compute_provider.aws.ec2 fields:
@@ -414,6 +516,16 @@ variable "ec2_deployment_specs" {
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.
+
+ compute_provider.aws.microvm fields:
+ - image_arn/image_version: Lambda MicroVM image and optional version.
+ - ingress_network_connectors/egress_network_connectors: Up to 10 Lambda
+ network-connector ARNs in each direction.
+ - runtime logs: The upstream MicroVM provider creates and manages a log group
+ for each runner configuration from the common observability settings.
+ - maximum_duration_in_seconds: Optional integer lifetime from 1 through 28,800 seconds.
+ - environment_variables: Provider-specific environment variables.
+ - iam: Image and MicroVM resource ARNs plus optional scale-up and pool policies.
EOT
}