diff --git a/modules/compute-providers/aws/ec2/outputs.tf b/modules/compute-providers/aws/ec2/outputs.tf
index 422383df0f..83b2647fca 100644
--- a/modules/compute-providers/aws/ec2/outputs.tf
+++ b/modules/compute-providers/aws/ec2/outputs.tf
@@ -16,6 +16,8 @@ output "resources" {
output "provider" {
description = "Nested EC2 compute-provider contract consumed by runner-config."
value = {
+ type = "ec2"
+ capabilities = { scale_set = local.scale_set_capability }
environment_variables = local.provider_environment_variables
policies = local.provider_policies
resources = local.provider_resources
diff --git a/modules/compute-providers/aws/ec2/scale-set.tf b/modules/compute-providers/aws/ec2/scale-set.tf
new file mode 100644
index 0000000000..6d9018a36e
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/scale-set.tf
@@ -0,0 +1,255 @@
+# Provider-owned runtime and IAM fragments for the additive scale-set
+# orchestration capability. GitHub credentials, GitHub scope, desired capacity,
+# and boot timeout remain orchestration-owned and are not serialized here.
+locals {
+ scale_set_ec2_instance_criteria = merge(
+ {
+ instanceTypes = var.config.instance_types
+ targetCapacityType = var.config.instance_target_capacity_type
+ instanceAllocationStrategy = var.config.instance_allocation_strategy
+ },
+ var.config.instance_type_priorities == null ? {} : {
+ instanceTypePriorities = var.config.instance_type_priorities
+ },
+ var.config.instance_max_spot_price == null ? {} : {
+ maxSpotPrice = var.config.instance_max_spot_price
+ },
+ )
+
+ scale_set_runtime_configuration = merge(
+ {
+ region = var.aws_region
+ environment = var.prefix
+ runnerNamePrefix = var.runner.name_prefix
+ jitConfigParameterPath = "${var.ssm.paths.root}/${var.ssm.paths.tokens}"
+ subnets = var.config.subnet_ids
+ launchTemplateName = aws_launch_template.runner.name
+ ec2instanceCriteria = local.scale_set_ec2_instance_criteria
+ onDemandFailoverOnError = var.config.on_demand_failover_for_errors
+ scaleErrors = var.config.scale_errors
+ useDedicatedHost = var.config.use_dedicated_host
+ ssmParameterTags = [
+ for key in sort(keys(local.ssm_parameter_tags)) : {
+ Key = key
+ Value = local.ssm_parameter_tags[key]
+ }
+ ]
+ },
+ local.ami_id_ssm_external ? {
+ amiIdSsmParameterName = local.ami_id_ssm_parameter_name
+ } : {},
+ )
+
+ scale_set_owned_instance_conditions = [
+ {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:Application"
+ values = toset(["github-action-runner"])
+ },
+ {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:created_by"
+ values = toset(["scale-set-service"])
+ },
+ {
+ test = "StringEquals"
+ variable = "ec2:ResourceTag/ghr:environment"
+ values = toset([var.prefix])
+ },
+ ]
+
+ scale_set_owned_request_conditions = [
+ {
+ test = "StringEquals"
+ variable = "aws:RequestTag/ghr:Application"
+ values = toset(["github-action-runner"])
+ },
+ {
+ test = "StringEquals"
+ variable = "aws:RequestTag/ghr:created_by"
+ values = toset(["scale-set-service"])
+ },
+ {
+ test = "StringEquals"
+ variable = "aws:RequestTag/ghr:environment"
+ values = toset([var.prefix])
+ },
+ ]
+
+ scale_set_launch_dependency_resources = toset(concat(
+ [
+ "arn:${var.aws_partition}:ec2:${var.aws_region}::image/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:*:snapshot/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:dedicated-host/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:network-interface/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:placement-group/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:security-group/*",
+ aws_launch_template.runner.arn,
+ ],
+ [
+ for subnet_id in var.config.subnet_ids :
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:subnet/${subnet_id}"
+ ],
+ var.config.key_name == null ? [] : [
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:key-pair/${var.config.key_name}",
+ ],
+ ))
+
+ scale_set_create_fleet_dependency_resources = toset(concat(
+ [
+ "arn:${var.aws_partition}:ec2:${var.aws_region}::image/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:placement-group/*",
+ aws_launch_template.runner.arn,
+ ],
+ [
+ for subnet_id in var.config.subnet_ids :
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:subnet/${subnet_id}"
+ ],
+ ))
+
+ scale_set_iam_statements = merge(
+ {
+ describe_ec2 = {
+ actions = toset([
+ "ec2:DescribeInstances",
+ "ec2:DescribeLaunchTemplateVersions",
+ "ec2:DescribeTags",
+ ])
+ # These EC2 Describe APIs do not support resource-level permissions.
+ resources = toset(["*"])
+ conditions = []
+ }
+ create_fleet_dependencies = {
+ actions = toset(["ec2:CreateFleet"])
+ resources = local.scale_set_create_fleet_dependency_resources
+ conditions = []
+ }
+ create_owned_fleet_capacity = {
+ actions = toset(["ec2:CreateFleet"])
+ resources = toset([
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:fleet/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:volume/*",
+ ])
+ conditions = local.scale_set_owned_request_conditions
+ }
+ run_instances_dependencies = {
+ actions = toset(["ec2:RunInstances"])
+ resources = local.scale_set_launch_dependency_resources
+ conditions = []
+ }
+ run_owned_instances = {
+ actions = toset(["ec2:RunInstances"])
+ resources = toset([
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*",
+ "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:volume/*",
+ ])
+ conditions = local.scale_set_owned_request_conditions
+ }
+ tag_runners_on_create = {
+ actions = toset(["ec2:CreateTags"])
+ resources = toset(["arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*/*"])
+ conditions = [
+ {
+ test = "StringEquals"
+ variable = "ec2:CreateAction"
+ values = toset(["CreateFleet", "RunInstances"])
+ },
+ ]
+ }
+ update_owned_runner_tags = {
+ actions = toset(["ec2:CreateTags"])
+ resources = toset(["arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*"])
+ conditions = concat(local.scale_set_owned_instance_conditions, [
+ {
+ test = "ForAllValues:StringEquals"
+ variable = "aws:TagKeys"
+ values = toset([
+ "ghr:github_runner_id",
+ "ghr:runner_name",
+ "ghr:scale_set_state",
+ ])
+ },
+ ])
+ }
+ terminate_owned_runners = {
+ actions = toset(["ec2:TerminateInstances"])
+ resources = toset(["arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*"])
+ conditions = local.scale_set_owned_instance_conditions
+ }
+ pass_runner_role = {
+ actions = toset(["iam:PassRole"])
+ resources = toset([var.runner.iam.role.arn])
+ conditions = [
+ {
+ test = "StringEquals"
+ variable = "iam:PassedToService"
+ values = toset(["ec2.amazonaws.com"])
+ },
+ ]
+ }
+ publish_runner_jit_configuration = {
+ actions = toset([
+ "ssm:AddTagsToResource",
+ "ssm:DeleteParameter",
+ "ssm:PutParameter",
+ ])
+ resources = toset([
+ "${local.ssm_parameter_arn_prefix}${var.ssm.paths.root}/${var.ssm.paths.tokens}/*",
+ ])
+ conditions = []
+ }
+ },
+ local.ami_id_ssm_external ? {
+ read_external_ami_parameter = {
+ actions = toset(["ssm:GetParameter"])
+ resources = toset([local.ami_id_ssm_parameter_arn])
+ conditions = []
+ }
+ } : {},
+ local.ami_kms_key_enabled ? {
+ use_ami_kms_key = {
+ actions = toset([
+ "kms:Decrypt",
+ "kms:DescribeKey",
+ "kms:ReEncryptFrom",
+ "kms:ReEncryptTo",
+ ])
+ resources = toset([local.ami_kms_key_arn])
+ conditions = []
+ }
+ create_ami_kms_grant = {
+ actions = toset(["kms:CreateGrant"])
+ resources = toset([local.ami_kms_key_arn])
+ conditions = [
+ {
+ test = "Bool"
+ variable = "kms:GrantIsForAWSResource"
+ values = toset(["true"])
+ },
+ ]
+ }
+ } : {},
+ var.config.create_service_linked_role_spot ? {
+ create_spot_service_linked_role = {
+ actions = toset(["iam:CreateServiceLinkedRole"])
+ resources = toset([
+ "arn:${var.aws_partition}:iam::${data.aws_caller_identity.current.account_id}:role/aws-service-role/spot.amazonaws.com/AWSServiceRoleForEC2Spot",
+ ])
+ conditions = [
+ {
+ test = "StringEquals"
+ variable = "iam:AWSServiceName"
+ values = toset(["spot.amazonaws.com"])
+ },
+ ]
+ }
+ } : {},
+ )
+
+ scale_set_capability = {
+ configuration_json = jsonencode(local.scale_set_runtime_configuration)
+ environment_variables = {}
+ iam_statements = local.scale_set_iam_statements
+ }
+}
diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md
index 6d6a84c3ad..afbe99063d 100644
--- a/modules/multi-runner/README.md
+++ b/modules/multi-runner/README.md
@@ -119,6 +119,7 @@ module "multi-runner" {
|------|--------|---------|
| [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a |
| [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a |
+| [orchestration\_scale\_set](#module\_orchestration\_scale\_set) | ../orchestration-providers/scale-set | n/a |
| [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a |
| [runner\_configs](#module\_runner\_configs) | ../runner-config | n/a |
| [runners](#module\_runners) | ../runners | n/a |
@@ -166,7 +167,7 @@ module "multi-runner" {
| [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.
global\_config\_github = {
app: {
key\_base64: "Base64-encoded GitHub App private key."
key\_base64\_ssm: "SSM parameter containing the Base64-encoded GitHub App private key."
key\_base64\_ssm.arn: "ARN of the SSM parameter containing the GitHub App private key."
key\_base64\_ssm.name: "Name of the SSM parameter containing the GitHub App private key."
id: "GitHub App ID."
id\_ssm: "SSM parameter containing the GitHub App ID."
id\_ssm.arn: "ARN of the SSM parameter containing the GitHub App ID."
id\_ssm.name: "Name of the SSM parameter containing the GitHub App ID."
webhook\_secret: "GitHub App webhook secret."
webhook\_secret\_ssm: "SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.arn: "ARN of the SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.name: "Name of the SSM parameter containing the GitHub App webhook secret."
}
additional\_apps: "Additional GitHub Apps used to distribute GitHub API requests."
additional\_apps.key\_base64: "Base64-encoded private key for an additional GitHub App."
additional\_apps.key\_base64\_ssm: "SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.arn: "ARN of the SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.name: "Name of the SSM parameter containing an additional App private key."
additional\_apps.id: "ID of an additional GitHub App."
additional\_apps.id\_ssm: "SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.arn: "ARN of the SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.name: "Name of the SSM parameter containing an additional GitHub App ID."
additional\_apps.installation\_id: "Optional installation ID for an additional GitHub App."
additional\_apps.installation\_id\_ssm: "SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.arn: "ARN of the SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.name: "Name of the SSM parameter containing an additional App installation ID."
enterprise\_server.url: "GitHub Enterprise Server URL."
enterprise\_server.ssl\_verify: "Whether to verify the GitHub Enterprise Server TLS certificate."
user\_agent: "User-Agent value sent with GitHub API requests."
} |
object({
app = optional(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}), null)
additional_apps = optional(list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}) | `{}` | no |
| [global\_config\_lambda](#input\_global\_config\_lambda) | Global Lambda configuration shared by all runner lanes.object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}) | `{}` | no |
| [global\_config\_observability](#input\_global\_config\_observability) | Global observability configuration shared by all runner lanes.object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(object({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
}) | `{}` | no |
-| [global\_config\_orchestration\_provider](#input\_global\_config\_orchestration\_provider) | Global orchestration-provider configuration shared by all runner lanes.object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enabled = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
repository_white_list = optional(list(string), [])
}), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
}) | `{}` | no |
+| [global\_config\_orchestration\_provider](#input\_global\_config\_orchestration\_provider) | Global orchestration-provider configuration shared by all runner lanes.object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enabled = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
repository_white_list = optional(list(string), [])
}), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
scale_set = optional(object({
grouping = optional(object({
strategy = optional(string, "compute_provider")
custom = optional(object({
groups = map(object({
runner_configs = set(string)
}))
}), null)
}), {})
container = optional(object({
image = optional(string, null)
user = optional(string, "10001:10001")
health_port = optional(number, 8080)
health_path = optional(string, "/healthz")
health_check_command = optional(list(string), null)
health_check_interval = optional(number, 30)
health_check_timeout = optional(number, 5)
health_check_retries = optional(number, 3)
health_check_start_period = optional(number, 30)
health_stale_after_seconds = optional(number, 180)
shutdown_timeout_seconds = optional(number, 110)
session_close_timeout_seconds = optional(number, 10)
reconnect_initial_backoff_seconds = optional(number, 1)
reconnect_max_backoff_seconds = optional(number, 30)
stop_timeout_seconds = optional(number, 120)
ecr_repository = optional(object({
arn = string
}), null)
}), {})
config_store = optional(object({
path_prefix = optional(string, null)
tier = optional(string, "Standard")
tags = optional(map(string), {})
}), {})
ecs = optional(object({
cluster = optional(object({
mode = optional(string, "managed")
arn = optional(string, null)
name = optional(string, null)
container_insights = optional(bool, true)
}), {})
task = optional(object({
cpu = optional(number, 512)
memory = optional(number, 1024)
cpu_architecture = optional(string, "X86_64")
ephemeral_storage = optional(object({
size_in_gib = number
}), null)
}), {})
service = optional(object({
platform_version = optional(string, "LATEST")
}), {})
iam = optional(object({
path = optional(string, "/")
permissions_boundary = optional(string, null)
}), {})
}), {})
network = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(set(string), null)
https_egress = optional(object({
ipv4_cidrs = optional(set(string), ["0.0.0.0/0"])
ipv6_cidrs = optional(set(string), [])
}), {})
}), {})
logging = optional(object({
retention_in_days = optional(number, 30)
kms_key_arn = optional(string, null)
log_group_class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tags = optional(map(string), {})
}), {})
}) | `{}` | no |
| [global\_config\_ssm](#input\_global\_config\_ssm) | Global SSM configuration shared by all runner lanes.object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
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, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}) | `{}` | no |
| [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances. | object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}) | {
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
} | no |
| [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no |
@@ -239,6 +240,7 @@ module "multi-runner" {
| [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a |
| [runners\_map](#output\_runners\_map) | n/a |
| [runners\_map\_v2](#output\_runners\_map\_v2) | n/a |
+| [scale\_set](#output\_scale\_set) | Shared scale-set orchestration resources, or null when no runner configuration selects scale\_set. |
| [ssm\_parameters](#output\_ssm\_parameters) | n/a |
| [webhook](#output\_webhook) | n/a |
diff --git a/modules/multi-runner/config.experimental.effective.tf b/modules/multi-runner/config.experimental.effective.tf
index 28831a0033..6f71fd2ae3 100644
--- a/modules/multi-runner/config.experimental.effective.tf
+++ b/modules/multi-runner/config.experimental.effective.tf
@@ -35,6 +35,7 @@ locals {
artifact = local.normalized_config.orchestration_provider.webhook.lambda.artifact
})
})
+ scale_set = v.orchestration_provider.scale_set
}
ssm = merge(v.ssm, {
diff --git a/modules/multi-runner/config.experimental.resolved.tf b/modules/multi-runner/config.experimental.resolved.tf
index b7e2e26ab0..c3efbf05c2 100644
--- a/modules/multi-runner/config.experimental.resolved.tf
+++ b/modules/multi-runner/config.experimental.resolved.tf
@@ -291,6 +291,7 @@ locals {
tags = merge(local.normalized_config.orchestration_provider.webhook.queue.tags, v.orchestration_provider.webhook.queue.tags)
})
})
+ scale_set = v.orchestration_provider.scale_set
}
ssm = merge(v.ssm, {
diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf
index 07df28e2e7..d0cf4eceb8 100644
--- a/modules/multi-runner/config.experimental.translation.tf
+++ b/modules/multi-runner/config.experimental.translation.tf
@@ -140,6 +140,7 @@ locals {
encryption = var.queue_encryption
}
}
+ scale_set = null
}
stable_to_v2_ssm = {
@@ -425,6 +426,7 @@ locals {
}
}
}
+ scale_set = null
}
ssm = {
diff --git a/modules/multi-runner/orchestration-provider.scale-set.tf b/modules/multi-runner/orchestration-provider.scale-set.tf
new file mode 100644
index 0000000000..8a38f12fa3
--- /dev/null
+++ b/modules/multi-runner/orchestration-provider.scale-set.tf
@@ -0,0 +1,74 @@
+locals {
+ scale_set_runner_config = {
+ for runner_name, runner_config in local.effective_config.multi_runner_config :
+ runner_name => runner_config
+ if runner_config.orchestration_provider.scale_set != null
+ }
+
+ scale_set_runner_configs = {
+ for runner_name, runner_config in local.scale_set_runner_config : runner_name => {
+ github = {
+ config_url = runner_config.orchestration_provider.scale_set.github.config_url
+ app = {
+ app_id = {
+ name = local.primary_app_id.name
+ arn = local.primary_app_id.arn
+ kms_key_arn = local.effective_config.ssm.kms_key_id
+ }
+ private_key = {
+ name = local.primary_app_key_base64.name
+ arn = local.primary_app_key_base64.arn
+ kms_key_arn = local.effective_config.ssm.kms_key_id
+ }
+ installation_id = {
+ name = runner_config.orchestration_provider.scale_set.github.installation_id_ssm.name
+ arn = runner_config.orchestration_provider.scale_set.github.installation_id_ssm.arn
+ kms_key_arn = runner_config.orchestration_provider.scale_set.github.installation_id_ssm.kms_key_arn
+ }
+ }
+ force_ghes = try(coalesce(
+ runner_config.orchestration_provider.scale_set.github.force_ghes,
+ local.effective_config.github.enterprise_server.url != null,
+ ), false)
+ ssl_verify = local.effective_config.github.enterprise_server.ssl_verify
+ user_agent = local.effective_config.github.user_agent
+ }
+ scale_set = {
+ name = runner_config.orchestration_provider.scale_set.name
+ id = runner_config.orchestration_provider.scale_set.id
+ runner_group_id = runner_config.orchestration_provider.scale_set.runner_group_id
+ min_runners = runner_config.orchestration_provider.scale_set.min_runners
+ max_runners = runner_config.orchestration_provider.scale_set.max_runners
+ boot_time_in_minutes = runner_config.orchestration_provider.scale_set.boot_time_in_minutes
+ session_owner = runner_config.orchestration_provider.scale_set.session_owner
+ }
+ work_folder = runner_config.orchestration_provider.scale_set.work_folder
+ }
+ }
+
+ scale_set_compute_provider_contracts = {
+ for runner_name in keys(local.scale_set_runner_configs) :
+ runner_name => module.runner_configs[runner_name].compute_provider_contract
+ }
+}
+
+module "orchestration_scale_set" {
+ source = "../orchestration-providers/scale-set"
+ count = length(local.scale_set_runner_configs) > 0 ? 1 : 0
+
+ prefix = var.prefix
+ runner_configs = local.scale_set_runner_configs
+ compute_provider_contracts = local.scale_set_compute_provider_contracts
+
+ grouping = try(local.effective_config.orchestration_provider.scale_set.grouping, {})
+ container = try(local.effective_config.orchestration_provider.scale_set.container, {})
+ config_store = try(local.effective_config.orchestration_provider.scale_set.config_store, {})
+ ecs = try(local.effective_config.orchestration_provider.scale_set.ecs, {})
+ network = try(local.effective_config.orchestration_provider.scale_set.network, {})
+ logging = try(local.effective_config.orchestration_provider.scale_set.logging, {})
+ tags = merge(
+ local.effective_config.tags,
+ try(local.effective_config.orchestration_provider.scale_set.tags, {}),
+ { "ghr:environment" = var.prefix },
+ )
+}
diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf
index 4c7d4a6cd3..0a9a1d5ac9 100644
--- a/modules/multi-runner/outputs.tf
+++ b/modules/multi-runner/outputs.tf
@@ -33,6 +33,16 @@ output "runners_map_v2" {
}
}
+output "scale_set" {
+ description = "Shared scale-set orchestration resources, or null when no runner configuration selects scale_set."
+ value = length(module.orchestration_scale_set) == 0 ? null : {
+ cluster = one(module.orchestration_scale_set[*].cluster)
+ controller_groups = one(module.orchestration_scale_set[*].controller_groups)
+ reconciler_config_parameters = one(module.orchestration_scale_set[*].reconciler_config_parameters)
+ resolved_container_image = one(module.orchestration_scale_set[*].resolved_container_image)
+ }
+}
+
output "binaries_syncer_map" {
value = { for runner_binary_key, runner_binary in module.runner_binaries : runner_binary_key => {
lambda = runner_binary.lambda
@@ -44,15 +54,15 @@ output "binaries_syncer_map" {
}
output "webhook" {
- value = {
- gateway = module.webhook.gateway
- lambda = module.webhook.lambda
- lambda_log_group = module.webhook.lambda_log_group
- lambda_role = module.webhook.role
- endpoint = "${module.webhook.gateway.api_endpoint}/${module.webhook.endpoint_relative_path}"
- webhook = module.webhook.webhook
- dispatcher = local.effective_config.orchestration_provider.webhook.eventbridge.enabled ? module.webhook.dispatcher : null
- eventbridge = local.effective_config.orchestration_provider.webhook.eventbridge.enabled ? module.webhook.eventbridge : null
+ value = length(module.webhook) == 0 ? null : {
+ gateway = module.webhook[0].gateway
+ lambda = module.webhook[0].lambda
+ lambda_log_group = module.webhook[0].lambda_log_group
+ lambda_role = module.webhook[0].role
+ endpoint = "${module.webhook[0].gateway.api_endpoint}/${module.webhook[0].endpoint_relative_path}"
+ webhook = module.webhook[0].webhook
+ dispatcher = length(module.webhook) > 0 && try(local.effective_config.orchestration_provider.webhook.eventbridge.enabled, false) ? module.webhook[0].dispatcher : null
+ eventbridge = length(module.webhook) > 0 && try(local.effective_config.orchestration_provider.webhook.eventbridge.enabled, false) ? module.webhook[0].eventbridge : null
}
}
diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf
index 0f57020571..b8794de893 100644
--- a/modules/multi-runner/queues.tf
+++ b/modules/multi-runner/queues.tf
@@ -27,7 +27,7 @@ data "aws_iam_policy_document" "deny_insecure_transport" {
}
resource "aws_sqs_queue" "queued_builds" {
- for_each = local.effective_config.multi_runner_config
+ for_each = local.webhook_runner_config
name = "${var.prefix}-${each.key}-queued-builds"
delay_seconds = each.value.orchestration_provider.webhook.queue.delay_webhook_event
visibility_timeout_seconds = each.value.orchestration_provider.webhook.queue.visibility_timeout_seconds
@@ -50,14 +50,14 @@ resource "aws_sqs_queue" "queued_builds" {
}
resource "aws_sqs_queue_policy" "build_queue_policy" {
- for_each = local.effective_config.multi_runner_config
+ for_each = local.webhook_runner_config
queue_url = aws_sqs_queue.queued_builds[each.key].id
policy = data.aws_iam_policy_document.deny_insecure_transport.json
}
resource "aws_sqs_queue" "queued_builds_dlq" {
for_each = {
- for config, values in local.effective_config.multi_runner_config : config => values
+ for config, values in local.webhook_runner_config : config => values
if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled
}
name = "${var.prefix}-${each.key}-queued-builds_dead_letter"
@@ -74,7 +74,7 @@ resource "aws_sqs_queue" "queued_builds_dlq" {
resource "aws_sqs_queue_policy" "build_queue_dlq_policy" {
for_each = {
- for config, values in local.effective_config.multi_runner_config : config => values
+ for config, values in local.webhook_runner_config : config => values
if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled
}
queue_url = aws_sqs_queue.queued_builds_dlq[each.key].id
diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf
index 4e8652aa00..9c081d4273 100644
--- a/modules/multi-runner/runners.experimental.tf
+++ b/modules/multi-runner/runners.experimental.tf
@@ -31,6 +31,7 @@ module "runner_configs" {
lambda = each.value.orchestration_provider.webhook.lambda
job_retry = each.value.orchestration_provider.webhook.job_retry
}
+ scale_set = each.value.orchestration_provider.scale_set
}
ssm = each.value.ssm
observability = each.value.observability
diff --git a/modules/multi-runner/tests/scale-set.tftest.hcl b/modules/multi-runner/tests/scale-set.tftest.hcl
new file mode 100644
index 0000000000..74a5ab0b30
--- /dev/null
+++ b/modules/multi-runner/tests/scale-set.tftest.hcl
@@ -0,0 +1,215 @@
+mock_provider "aws" {
+ mock_data "aws_caller_identity" {
+ defaults = {
+ account_id = "123456789012"
+ }
+ }
+
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ }
+ }
+
+ mock_resource "aws_iam_role" {
+ defaults = {
+ arn = "arn:aws:iam::123456789012:role/test-role"
+ }
+ }
+
+ mock_resource "aws_cloudwatch_event_bus" {
+ defaults = {
+ arn = "arn:aws:events:eu-west-1:123456789012:event-bus/test"
+ }
+ }
+
+ mock_resource "aws_cloudwatch_event_rule" {
+ defaults = {
+ arn = "arn:aws:events:eu-west-1:123456789012:rule/test"
+ }
+ }
+
+ mock_resource "aws_lambda_function" {
+ defaults = {
+ arn = "arn:aws:lambda:eu-west-1:123456789012:function:test"
+ }
+ }
+
+ mock_resource "aws_sqs_queue" {
+ defaults = {
+ arn = "arn:aws:sqs:eu-west-1:123456789012:test"
+ }
+ }
+
+ mock_resource "aws_s3_bucket" {
+ defaults = {
+ arn = "arn:aws:s3:::test-runner-binaries"
+ id = "test-runner-binaries"
+ }
+ }
+
+ mock_resource "aws_apigatewayv2_api" {
+ defaults = {
+ execution_arn = "arn:aws:execute-api:eu-west-1:123456789012:test"
+ }
+ }
+}
+
+mock_provider "random" {}
+mock_provider "null" {}
+
+variables {
+ aws_region = "eu-west-1"
+ aws_partition = "aws"
+ prefix = "scale-set-test"
+
+ experimental_global_config = {
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ }
+ }
+
+ experimental_global_config_github = {
+ app = {
+ key_base64 = "test-app-key"
+ id = "test-app-id"
+ webhook_secret = "test-webhook-secret"
+ }
+ }
+
+ experimental_global_config_lambda = {
+ artifact = {
+ s3 = {
+ bucket = "test-lambda-artifacts"
+ }
+ }
+ }
+
+ experimental_global_config_orchestration_provider = {
+ scale_set = {
+ grouping = {
+ strategy = "runner_config"
+ }
+ container = {
+ image = "public.ecr.aws/example/scale-set-controller@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ }
+ config_store = {
+ path_prefix = "/test/scale-set"
+ }
+ ecs = {
+ task = {
+ cpu = 512
+ memory = 1024
+ cpu_architecture = "X86_64"
+ }
+ }
+ network = {
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set-a", "subnet-scale-set-b"]
+ }
+ logging = {
+ retention_in_days = 7
+ }
+ }
+ }
+
+ experimental_global_config_ssm = {
+ kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/test"
+ housekeeper = {
+ lambda = {
+ artifact = {
+ s3 = {
+ key = "housekeeper.zip"
+ }
+ }
+ }
+ }
+ }
+
+ experimental_global_config_compute_provider = {
+ aws = {
+ ec2 = {
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set-a"]
+ runner_binaries = {
+ enabled = false
+ }
+ }
+ }
+ }
+
+ experimental_multi_runner_config = {
+ linux = {
+ runner = {
+ name_prefix = "linux-"
+ }
+ orchestration_provider = {
+ scale_set = {
+ github = {
+ config_url = "https://github.com/example"
+ installation_id_ssm = {
+ name = "/github/scale-set/installation-id"
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/scale-set/installation-id"
+ }
+ }
+ name = "linux-scale-set"
+ id = 42
+ runner_group_id = 7
+ min_runners = 1
+ max_runners = 8
+ boot_time_in_minutes = 12
+ session_owner = "test-owner"
+ work_folder = "_work/linux"
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m7i.large"]
+ subnet_ids = ["subnet-scale-set-a"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+run "routes_scale_set_through_runner_config_and_shared_controller" {
+ command = plan
+
+ assert {
+ condition = (
+ local.use_v2_config
+ && keys(local.resolved_config.multi_runner_config) == ["linux"]
+ && local.resolved_config.multi_runner_config.linux.orchestration_provider.scale_set.name == "linux-scale-set"
+ && local.resolved_config.multi_runner_config.linux.orchestration_provider.scale_set.id == 42
+ )
+ error_message = "The multi-runner resolver must preserve the lane scale_set contract and its plan-known identity."
+ }
+
+ assert {
+ condition = (
+ length(module.runners) == 0
+ && keys(module.runner_configs) == ["linux"]
+ && output.runners_map_v2.linux.orchestration_provider.scale_set.name == "linux-scale-set"
+ && output.runners_map_v2.linux.orchestration_provider.scale_set.id == 42
+ && output.runners_map_v2.linux.provider.aws.ec2 != null
+ )
+ error_message = "Scale-set lanes must use runner-config and expose the selected compute-provider namespace."
+ }
+
+ assert {
+ condition = (
+ output.scale_set != null
+ && output.scale_set.cluster.managed
+ && keys(output.scale_set.controller_groups) == ["linux"]
+ && output.scale_set.controller_groups.linux.runner_configs == ["linux"]
+ && output.scale_set.reconciler_config_parameters["linux/linux"].tier == "Standard"
+ )
+ error_message = "Multi-runner must create one shared scale-set controller group and reconciler parameter for the selected lane."
+ }
+}
diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf
index a738297e53..e74095349d 100644
--- a/modules/multi-runner/validations.tf
+++ b/modules/multi-runner/validations.tf
@@ -68,15 +68,19 @@ resource "terraform_data" "validate_v2" {
precondition {
condition = alltrue([
for config in local.resolved_config.multi_runner_config : (
- try(config.orchestration_provider.webhook != null, false) &&
- try(length(config.orchestration_provider.webhook.matcherConfig.labelMatchers) > 0, false) &&
+ (
+ (
+ try(config.orchestration_provider.webhook != null, false) &&
+ try(length(config.orchestration_provider.webhook.matcherConfig.labelMatchers) > 0, false)
+ ) || try(config.orchestration_provider.scale_set != null, false)
+ ) &&
try(config.compute_provider.aws.ec2 != null, false) &&
try(length(config.compute_provider.aws.ec2.instance_types) > 0, false) &&
try(config.compute_provider.aws.ec2.vpc_id != null, false) &&
try(length(config.compute_provider.aws.ec2.subnet_ids) > 0, false)
)
])
- error_message = "Each experimental v2 runner lane requires a webhook matcher, EC2 instance_types, vpc_id, and at least one subnet."
+ error_message = "Each experimental v2 runner lane requires either a webhook matcher or scale_set orchestration, plus EC2 instance_types, vpc_id, and at least one subnet."
}
}
}
diff --git a/modules/multi-runner/variables.experimental.orchestration-provider.tf b/modules/multi-runner/variables.experimental.orchestration-provider.tf
index fd962f9632..acb1b24a1e 100644
--- a/modules/multi-runner/variables.experimental.orchestration-provider.tf
+++ b/modules/multi-runner/variables.experimental.orchestration-provider.tf
@@ -171,6 +171,81 @@ variable "global_config_orchestration_provider" {
sqs_managed_sse_enabled = true
})
}), {})
+
+ }), {})
+
+ scale_set = optional(object({
+ grouping = optional(object({
+ strategy = optional(string, "compute_provider")
+ custom = optional(object({
+ groups = map(object({
+ runner_configs = set(string)
+ }))
+ }), null)
+ }), {})
+ container = optional(object({
+ image = optional(string, null)
+ user = optional(string, "10001:10001")
+ health_port = optional(number, 8080)
+ health_path = optional(string, "/healthz")
+ health_check_command = optional(list(string), null)
+ health_check_interval = optional(number, 30)
+ health_check_timeout = optional(number, 5)
+ health_check_retries = optional(number, 3)
+ health_check_start_period = optional(number, 30)
+ health_stale_after_seconds = optional(number, 180)
+ shutdown_timeout_seconds = optional(number, 110)
+ session_close_timeout_seconds = optional(number, 10)
+ reconnect_initial_backoff_seconds = optional(number, 1)
+ reconnect_max_backoff_seconds = optional(number, 30)
+ stop_timeout_seconds = optional(number, 120)
+ ecr_repository = optional(object({
+ arn = string
+ }), null)
+ }), {})
+ config_store = optional(object({
+ path_prefix = optional(string, null)
+ tier = optional(string, "Standard")
+ tags = optional(map(string), {})
+ }), {})
+ ecs = optional(object({
+ cluster = optional(object({
+ mode = optional(string, "managed")
+ arn = optional(string, null)
+ name = optional(string, null)
+ container_insights = optional(bool, true)
+ }), {})
+ task = optional(object({
+ cpu = optional(number, 512)
+ memory = optional(number, 1024)
+ cpu_architecture = optional(string, "X86_64")
+ ephemeral_storage = optional(object({
+ size_in_gib = number
+ }), null)
+ }), {})
+ service = optional(object({
+ platform_version = optional(string, "LATEST")
+ }), {})
+ iam = optional(object({
+ path = optional(string, "/")
+ permissions_boundary = optional(string, null)
+ }), {})
+ }), {})
+ network = optional(object({
+ vpc_id = optional(string, null)
+ subnet_ids = optional(set(string), null)
+ https_egress = optional(object({
+ ipv4_cidrs = optional(set(string), ["0.0.0.0/0"])
+ ipv6_cidrs = optional(set(string), [])
+ }), {})
+ }), {})
+ logging = optional(object({
+ retention_in_days = optional(number, 30)
+ kms_key_arn = optional(string, null)
+ log_group_class = optional(string, "STANDARD")
+ tags = optional(map(string), {})
+ }), {})
+ tags = optional(map(string), {})
}), {})
})
default = {}
diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf
index b48c3efbe2..f2af16d918 100644
--- a/modules/multi-runner/webhook.tf
+++ b/modules/multi-runner/webhook.tf
@@ -23,15 +23,16 @@ locals {
module "webhook" {
source = "../webhook"
+ count = length(local.webhook_runner_config) > 0 ? 1 : 0
prefix = var.prefix
tags = local.tags
kms_key_arn = local.effective_config.ssm.kms_key_id
eventbridge = {
- enable = local.effective_config.orchestration_provider.webhook.eventbridge.enabled
- accept_events = local.effective_config.orchestration_provider.webhook.eventbridge.accept_events
+ enable = try(local.effective_config.orchestration_provider.webhook.eventbridge.enabled, false)
+ accept_events = try(local.effective_config.orchestration_provider.webhook.eventbridge.accept_events, [])
}
runner_matcher_config = local.runner_matcher_config
- matcher_config_parameter_store_tier = local.effective_config.orchestration_provider.webhook.matcher_config_parameter_store_tier
+ matcher_config_parameter_store_tier = try(local.effective_config.orchestration_provider.webhook.matcher_config_parameter_store_tier, "Standard")
ssm_paths = {
root = local.ssm_root_path
@@ -45,13 +46,13 @@ module "webhook" {
lambda_s3_bucket = try(local.effective_config.lambda.artifact.s3.bucket, null)
webhook_lambda_s3_key = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.s3.key, null)
webhook_lambda_s3_object_version = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.s3.object_version, null)
- webhook_lambda_apigateway_access_log_settings = local.effective_config.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings
+ webhook_lambda_apigateway_access_log_settings = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.api_gateway_access_log_settings, null)
lambda_runtime = local.effective_config.lambda.runtime
lambda_architecture = local.effective_config.lambda.architecture
- lambda_zip = local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.zip
- lambda_timeout = local.effective_config.orchestration_provider.webhook.lambda.webhook.timeout
- lambda_memory_size = local.effective_config.orchestration_provider.webhook.lambda.webhook.memory_size
- lambda_tags = local.effective_config.orchestration_provider.webhook.lambda.webhook.tags
+ lambda_zip = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.zip, null)
+ lambda_timeout = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.timeout, null)
+ lambda_memory_size = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.memory_size, null)
+ lambda_tags = try(local.effective_config.orchestration_provider.webhook.lambda.webhook.tags, {})
tracing_config = local.effective_config.observability.tracing
logging_retention_in_days = local.effective_config.observability.logs.retention_in_days
logging_kms_key_id = local.effective_config.observability.logs.kms_key_id
@@ -59,8 +60,8 @@ module "webhook" {
role_path = local.effective_config.roles.path
role_permissions_boundary = local.effective_config.roles.permissions_boundary
- repository_white_list = local.effective_config.orchestration_provider.webhook.github.repository_white_list
- queue_selection_strategy = local.effective_config.orchestration_provider.webhook.queue_selection_strategy
+ repository_white_list = try(local.effective_config.orchestration_provider.webhook.github.repository_white_list, [])
+ queue_selection_strategy = try(local.effective_config.orchestration_provider.webhook.queue_selection_strategy, "first")
lambda_subnet_ids = local.effective_config.lambda.subnet_ids
lambda_security_group_ids = local.effective_config.lambda.security_group_ids
diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md
index c918a79fa0..898e8b8f56 100644
--- a/modules/runner-config/README.md
+++ b/modules/runner-config/README.md
@@ -114,7 +114,7 @@ yarn run dist
| [github](#input\_github) | GitHub API and runner-registration configuration.object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
}) | n/a | yes |
| [lambda](#input\_lambda) | Common Lambda substrate independent of the selected runner orchestration provider.object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}) | `{}` | no |
| [observability](#input\_observability) | Logging, tracing, and metrics configuration for control-plane and provider resources.object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(object({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
}) | `{}` | no |
-| [orchestration\_provider](#input\_orchestration\_provider) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply.object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = 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)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
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)
}) | n/a | yes |
+| [orchestration\_provider](#input\_orchestration\_provider) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply.object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = 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)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
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)
scale_set = optional(object({
github = object({
config_url = string
installation_id_ssm = object({
name = string
arn = string
kms_key_arn = optional(string, null)
})
force_ghes = optional(bool, null)
})
name = string
id = number
runner_group_id = optional(number, null)
min_runners = optional(number, 0)
max_runners = optional(number, 10)
boot_time_in_minutes = optional(number, 10)
session_owner = optional(string, null)
work_folder = optional(string, null)
}), null)
}) | n/a | yes |
| [prefix](#input\_prefix) | The prefix used for naming resources. | `string` | `"github-actions"` | no |
| [runner](#input\_runner) | Provider-neutral GitHub runner configuration.object({
os = optional(string, "linux")
architecture = optional(string, "x64")
disable_default_labels = optional(bool, false)
labels = list(string)
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}) | n/a | yes |
| [ssm](#input\_ssm) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.object({
paths = object({
root = string
tokens = string
config = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
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, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
}) | n/a | yes |
@@ -124,6 +124,7 @@ yarn run dist
| Name | Description |
|------|-------------|
+| [compute\_provider\_contract](#output\_compute\_provider\_contract) | Provider-neutral compute-provider capabilities consumed by topology-level orchestration. |
| [orchestration\_provider](#output\_orchestration\_provider) | Resources grouped under the selected runner orchestration provider. |
| [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. |
| [provider](#output\_provider) | Provider-specific resources grouped under the selected provider namespace and type. |
diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf
index 25994beaba..4d6c4c4444 100644
--- a/modules/runner-config/orchestration-provider.tf
+++ b/modules/runner-config/orchestration-provider.tf
@@ -7,11 +7,16 @@ locals {
orchestration_provider_type = one(keys(local.orchestration_providers))
orchestration_provider_enabled = {
- webhook = local.orchestration_provider_type == "webhook"
+ webhook = local.orchestration_provider_type == "webhook"
+ scale_set = local.orchestration_provider_type == "scale_set"
}
orchestration_provider_runner_lifecycle = {
webhook = one(module.orchestration_webhook[*].runner_lifecycle)
+ scale_set = {
+ ephemeral = true
+ jit_config_enabled = true
+ }
}[local.orchestration_provider_type]
}
diff --git a/modules/runner-config/outputs.tf b/modules/runner-config/outputs.tf
index 486e3261eb..39e511f9cd 100644
--- a/modules/runner-config/outputs.tf
+++ b/modules/runner-config/outputs.tf
@@ -29,6 +29,15 @@ output "orchestration_provider" {
pool = one(module.orchestration_webhook[*].pool)
job_retry = one(module.orchestration_webhook[*].job_retry)
} : null
+ scale_set = local.orchestration_provider_enabled.scale_set ? var.orchestration_provider.scale_set : null
+ }
+}
+
+output "compute_provider_contract" {
+ description = "Provider-neutral compute-provider capabilities consumed by topology-level orchestration."
+ value = {
+ type = local.provider_contract.type
+ capabilities = local.provider_contract.capabilities
}
}
diff --git a/modules/runner-config/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl
index 12a81854c3..8c63c7c12b 100644
--- a/modules/runner-config/tests/pool.tftest.hcl
+++ b/modules/runner-config/tests/pool.tftest.hcl
@@ -210,8 +210,9 @@ run "plan_with_pool_enabled" {
assert {
condition = (
- toset(keys(output.orchestration_provider)) == toset(["webhook"])
+ toset(keys(output.orchestration_provider)) == toset(["scale_set", "webhook"])
&& output.orchestration_provider.webhook != null
+ && output.orchestration_provider.scale_set == null
&& output.orchestration_provider.webhook.scale_up != null
&& output.orchestration_provider.webhook.scale_down != null
&& output.orchestration_provider.webhook.pool != null
diff --git a/modules/runner-config/tests/scale-set.tftest.hcl b/modules/runner-config/tests/scale-set.tftest.hcl
new file mode 100644
index 0000000000..4132bcf236
--- /dev/null
+++ b/modules/runner-config/tests/scale-set.tftest.hcl
@@ -0,0 +1,139 @@
+mock_provider "aws" {
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ }
+ }
+
+ mock_resource "aws_iam_role" {
+ defaults = {
+ arn = "arn:aws:iam::123456789012:role/runner-test"
+ }
+ }
+
+ mock_resource "aws_ssm_parameter" {
+ defaults = {
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id"
+ }
+ }
+}
+
+# The runner archive is injected during packaging; isolate the common
+# housekeeper child so this source-checkout test remains deterministic.
+override_module {
+ target = module.ssm_housekeeper
+}
+
+variables {
+ aws_region = "eu-west-1"
+
+ runner = {
+ labels = ["self-hosted", "linux", "x64"]
+ }
+
+ github = {
+ app_parameters = {
+ key_base64 = [{
+ name = "/github-runner/key-base64"
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64"
+ }]
+ id = [{
+ name = "/github-runner/app-id"
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id"
+ }]
+ installation_id = [null]
+ }
+ }
+
+ lambda = {
+ artifact = {
+ s3 = {
+ bucket = "test-lambda-bucket"
+ }
+ }
+ }
+
+ ssm = {
+ paths = {
+ root = "/github-runner"
+ tokens = "tokens"
+ config = "config"
+ }
+ }
+
+ compute_provider = {
+ aws = {
+ ec2 = {
+ vpc_id = "vpc-12345678"
+ subnet_ids = ["subnet-12345678"]
+ instance_types = ["m5.large"]
+ ami = {
+ filter = { state = ["available"] }
+ owners = ["amazon"]
+ id_ssm_parameter = {
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/external-ami-id"
+ }
+ kms_key = null
+ }
+ binaries_syncer = {
+ s3 = {
+ arn = "arn:aws:s3:::test-runner-binaries"
+ id = "test-runner-binaries"
+ key = "runners/linux/actions-runner.tar.gz"
+ }
+ }
+ }
+ }
+ }
+
+ orchestration_provider = {
+ scale_set = {
+ github = {
+ config_url = "https://github.com/example"
+ installation_id_ssm = {
+ name = "/github-runner/installation-id"
+ arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id"
+ }
+ }
+ name = "linux-scale-set"
+ id = 42
+ min_runners = 1
+ max_runners = 4
+ work_folder = "_work/linux-scale-set"
+ }
+ }
+}
+
+run "selects_scale_set_and_forces_ephemeral_jit" {
+ command = plan
+
+ assert {
+ condition = (
+ local.orchestration_provider_type == "scale_set"
+ && length(module.orchestration_webhook) == 0
+ && output.orchestration_provider.webhook == null
+ && output.orchestration_provider.scale_set.name == "linux-scale-set"
+ && output.orchestration_provider.scale_set.id == 42
+ )
+ error_message = "Runner-config must select scale_set as the only orchestration provider and preserve its identity contract."
+ }
+
+ assert {
+ condition = (
+ output.scale_up == null
+ && output.scale_down == null
+ && output.pool == null
+ && aws_ssm_parameter.runner_agent_mode.value == "ephemeral"
+ && aws_ssm_parameter.jit_config_enabled.value == "true"
+ )
+ error_message = "Scale-set orchestration must omit webhook controls and force ephemeral JIT runner registration."
+ }
+
+ assert {
+ condition = (
+ output.compute_provider_contract.type == "ec2"
+ && output.compute_provider_contract.capabilities.scale_set.configuration_json != "{}"
+ )
+ error_message = "Runner-config must expose the selected compute provider's scale_set capability for the shared controller."
+ }
+}
diff --git a/modules/runner-config/validations.tf b/modules/runner-config/validations.tf
index f510bf0422..0c4ff5bcf0 100644
--- a/modules/runner-config/validations.tf
+++ b/modules/runner-config/validations.tf
@@ -90,7 +90,12 @@ resource "terraform_data" "validate_config" {
for provider_name, provider_config in var.orchestration_provider : provider_name
if provider_config != null
]) == 1
- error_message = "Exactly one orchestration provider must be configured. Supported providers: webhook."
+ error_message = "Exactly one orchestration provider must be configured. Supported providers: webhook and scale_set."
+ }
+
+ precondition {
+ condition = var.orchestration_provider.scale_set == null ? true : local.provider_contract.capabilities.scale_set != null
+ error_message = "The selected compute provider must expose a scale_set capability when scale_set orchestration is selected."
}
precondition {
diff --git a/modules/runner-config/variables.orchestration-provider.tf b/modules/runner-config/variables.orchestration-provider.tf
index 6d176ad6d7..a5e6221954 100644
--- a/modules/runner-config/variables.orchestration-provider.tf
+++ b/modules/runner-config/variables.orchestration-provider.tf
@@ -3,7 +3,8 @@ variable "orchestration_provider" {
description = <<-EOT
Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply.
- - `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. Future providers can be added as sibling blocks without moving this contract.
+ - `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls.
+ - `scale_set`: Selects scale-set orchestration for this runner config. The multi-runner topology owns the shared controller service and passes this plan-known selection marker to runner-config. Scale-set runners always use ephemeral JIT registration.
- `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`.
- `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`.
@@ -54,6 +55,17 @@ variable "orchestration_provider" {
- `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`.
- `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`.
+ - `scale_set`: Selects scale-set orchestration for this runner configuration.
+ - `scale_set.github.config_url`: GitHub Actions scale-set configuration URL.
+ - `scale_set.github.installation_id_ssm`: SSM parameter containing the GitHub App installation ID.
+ - `scale_set.name`: Name of the scale set.
+ - `scale_set.id`: Numeric scale-set ID.
+ - `scale_set.runner_group_id`: Optional GitHub runner-group ID.
+ - `scale_set.min_runners`: Minimum number of scale-set runners. The default is `0`.
+ - `scale_set.max_runners`: Maximum number of scale-set runners. The default is `10`.
+ - `scale_set.boot_time_in_minutes`: Expected scale-set runner boot duration. The default is `10`.
+ - `scale_set.session_owner`: Optional owner for scale-set runner sessions.
+ - `scale_set.work_folder`: Optional runner work folder.
EOT
type = object({
webhook = optional(object({
@@ -135,6 +147,25 @@ variable "orchestration_provider" {
}), {})
}), {})
}), null)
+ scale_set = optional(object({
+ github = object({
+ config_url = string
+ installation_id_ssm = object({
+ name = string
+ arn = string
+ kms_key_arn = optional(string, null)
+ })
+ force_ghes = optional(bool, null)
+ })
+ name = string
+ id = number
+ runner_group_id = optional(number, null)
+ min_runners = optional(number, 0)
+ max_runners = optional(number, 10)
+ boot_time_in_minutes = optional(number, 10)
+ session_owner = optional(string, null)
+ work_folder = optional(string, null)
+ }), null)
})
nullable = false