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..dcbefd55f4
--- /dev/null
+++ b/modules/compute-providers/aws/ec2/scale-set.tf
@@ -0,0 +1,257 @@
+# 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
+ 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 = []
+ }
+ read_ami_parameter = {
+ actions = toset([
+ "ssm:GetParameter",
+ "ssm:GetParameters",
+ ])
+ resources = toset([
+ local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : 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/compute-providers/aws/ec2/tests/provider.tftest.hcl b/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl
index bc92537279..99feb7ef8e 100644
--- a/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl
+++ b/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl
@@ -138,6 +138,15 @@ run "separates_control_plane_contract_from_ec2_resources" {
error_message = "An external AMI SSM parameter must enable the pool managed policy attachment at plan time."
}
+ assert {
+ condition = (
+ contains(output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.actions, "ssm:GetParameter")
+ && contains(output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.actions, "ssm:GetParameters")
+ && contains(output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id")
+ )
+ error_message = "The scale-set compute role must read an external AMI parameter with both single and batched SSM actions."
+ }
+
assert {
condition = (
contains(flatten([
@@ -199,6 +208,26 @@ run "separates_control_plane_contract_from_ec2_resources" {
}
+run "includes_managed_ami_read_in_scale_set_contract" {
+ command = plan
+
+ variables {
+ config = merge(var.config, {
+ ami = merge(var.config.ami, {
+ id_ssm_parameter = null
+ })
+ })
+ }
+
+ assert {
+ condition = (
+ contains(output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.actions, "ssm:GetParameters")
+ && output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.resources != toset([])
+ )
+ error_message = "The scale-set compute role must read the module-managed AMI parameter."
+ }
+}
+
run "accepts_partial_typed_compute_options" {
command = plan
diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md
index 2866af815e..404a281580 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 |
@@ -160,13 +161,13 @@ module "multi-runner" {
| [experimental\_features](#input\_experimental\_features) | Explicit acknowledgement for opt-in features whose schemas may change
while experimental. Set to ["multi-runner-v2"] when using the v2
provider-boundary configuration. This flag will become a deprecated no-op
for one release when the feature graduates. | `set(string)` | `[]` | no |
| [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no |
| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no |
-| [github\_app](#input\_github\_app) | GitHub app parameters for the stable v1 interface, see your github app.
Omit this value when using the experimental v2 interface and provide the
app through `global_config_github` instead.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
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
}))
}) | `{}` | no |
+| [github\_app](#input\_github\_app) | GitHub app parameters for the stable v1 interface, see your github app.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
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}) | `{}` | no |
| [global\_config](#input\_global\_config) | Global defaults shared by all runner lanes.object({
tags = optional(map(string), {})
roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
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)
}), {})
}), {})
}) | `{}` | no |
| [global\_config\_compute\_provider](#input\_global\_config\_compute\_provider) | Global compute-provider configuration shared by all runner lanes.object({
selections = optional(map(object({
namespace = string
type = string
})), null)
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
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
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
runner_deregistration = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_handler = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_notification_watcher = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})
}) | `{}` | no |
-| [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.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\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.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
}))
installation_id = optional(string)
installation_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)
}), {})
runner_owner = optional(string, null)
runner_registration_level = optional(string, "organization")
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_confirmation_seconds = optional(number, 0)
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_confirmation_seconds = optional(number, 0)
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)
}), {})
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 |
@@ -188,7 +189,7 @@ module "multi-runner" {
| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no |
| [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no |
| [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. | object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}) | `{}` | no |
-| [multi\_runner\_config](#input\_multi\_runner\_config) | Accepts either the stable v1 runner configuration shape or the provider-boundary v2 shape. Entries with `runner_config` use the v1 shape; entries without `runner_config` use the v2 shape. A v2 entry does not need matcher configuration. A v2 entry must be acknowledged with `experimental_features = ["multi-runner-v2"]`; the v2 shape is experimental and may change before graduation.map(object({
# V1 contract
runner_config = optional(object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
scale_down_idle_confirmation_seconds = optional(number, 0)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
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)
runner_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)
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
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
iam_overrides = optional(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)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
}), null)
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
# V2 Contract
tags = optional(map(string), {})
runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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 = optional(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 = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = 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)
}), 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_confirmation_seconds = 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)
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)
}), {})
}), {})
}), {})
}), {})
compute_provider = optional(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 = optional(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), [])
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)
}), {})
}), {})
})) | `{}` | no |
+| [multi\_runner\_config](#input\_multi\_runner\_config) | Accepts either the stable v1 runner configuration shape or the provider-boundary v2 shape. Entries with `runner_config` use the v1 shape; entries without `runner_config` use the v2 shape. A v2 entry does not need matcher configuration. A v2 entry must be acknowledged with `experimental_features = ["multi-runner-v2"]`; the v2 shape is experimental and may change before graduation.map(object({
# V1 contract
runner_config = optional(object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
scale_down_idle_confirmation_seconds = optional(number, 0)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
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)
runner_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)
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
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
iam_overrides = optional(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)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
}), null)
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
# V2 Contract
tags = optional(map(string), {})
runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
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 = optional(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 = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = 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)
}), 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_confirmation_seconds = 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)
scale_set = optional(object({
name = string
runner = optional(object({
min_runners = optional(number, 0)
max_runners = optional(number, 10)
boot_time_in_minutes = optional(number, 10)
}), {})
}), 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)
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)
}), {})
}), {})
}), {})
}), {})
compute_provider = optional(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 = optional(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), [])
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)
}), {})
}), {})
})) | `{}` | no |
| [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no |
| [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no |
| [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | 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 1a53a16520..4e54b5070b 100644
--- a/modules/multi-runner/config.experimental.effective.tf
+++ b/modules/multi-runner/config.experimental.effective.tf
@@ -16,8 +16,10 @@ locals {
})
github = {
- enterprise_server = local.normalized_config.github.enterprise_server
- user_agent = local.normalized_config.github.user_agent
+ enterprise_server = local.normalized_config.github.enterprise_server
+ runner_owner = local.normalized_config.github.runner_owner
+ runner_registration_level = local.normalized_config.github.runner_registration_level
+ user_agent = local.normalized_config.github.user_agent
}
lambda = merge(v.lambda, {
@@ -35,6 +37,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 fc29e81a8d..a0643d0823 100644
--- a/modules/multi-runner/config.experimental.resolved.tf
+++ b/modules/multi-runner/config.experimental.resolved.tf
@@ -295,6 +295,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 301d37ec4d..fb8f09b96e 100644
--- a/modules/multi-runner/config.experimental.translation.tf
+++ b/modules/multi-runner/config.experimental.translation.tf
@@ -38,7 +38,9 @@ locals {
url = var.ghes_url
ssl_verify = var.ghes_ssl_verify
}
- user_agent = var.user_agent
+ runner_owner = null
+ runner_registration_level = "organization"
+ user_agent = var.user_agent
}
stable_to_v2_lambda = {
@@ -141,6 +143,7 @@ locals {
encryption = var.queue_encryption
}
}
+ scale_set = null
}
stable_to_v2_ssm = {
@@ -427,6 +430,7 @@ locals {
}
}
}
+ scale_set = null
}
ssm = {
diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf
index 23d51b7f65..99a41d5207 100644
--- a/modules/multi-runner/main.tf
+++ b/modules/multi-runner/main.tf
@@ -3,8 +3,9 @@ locals {
"ghr:environment" = var.prefix
})
- primary_app_id = coalesce(local.effective_config.github.app.id_ssm, module.ssm.parameters.github_app_id)
- primary_app_key_base64 = coalesce(local.effective_config.github.app.key_base64_ssm, module.ssm.parameters.github_app_key_base64)
+ primary_app_id = coalesce(local.effective_config.github.app.id_ssm, module.ssm.parameters.github_app_id)
+ primary_app_key_base64 = coalesce(local.effective_config.github.app.key_base64_ssm, module.ssm.parameters.github_app_key_base64)
+ primary_app_installation_id = local.effective_config.github.app.installation_id_ssm != null ? local.effective_config.github.app.installation_id_ssm : module.ssm.parameters.github_app_installation_id
github_app_parameters = {
id = local.primary_app_id
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..f18a7c1230
--- /dev/null
+++ b/modules/multi-runner/orchestration-provider.scale-set.tf
@@ -0,0 +1,62 @@
+locals {
+ scale_set_runner_configs = {
+ for runner_name, runner_config in local.effective_config.multi_runner_config : runner_name => {
+ github = {
+ enterprise_server = local.effective_config.github.enterprise_server
+ 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 = local.primary_app_installation_id == null ? null : {
+ name = local.primary_app_installation_id.name
+ arn = local.primary_app_installation_id.arn
+ kms_key_arn = local.effective_config.ssm.kms_key_id
+ }
+ }
+ runner_owner = local.effective_config.github.runner_owner
+ runner_registration_level = local.effective_config.github.runner_registration_level
+ user_agent = local.effective_config.github.user_agent
+ }
+ scale_set = {
+ name = runner_config.orchestration_provider.scale_set.name
+ runner = {
+ labels = runner_config.runner.labels
+ group_name = runner_config.runner.group_name
+ min_runners = runner_config.orchestration_provider.scale_set.runner.min_runners
+ max_runners = runner_config.orchestration_provider.scale_set.runner.max_runners
+ boot_time_in_minutes = runner_config.orchestration_provider.scale_set.runner.boot_time_in_minutes
+ }
+ }
+ compute_provider = module.runner_configs[runner_name].compute_provider_contract
+ }
+ if runner_config.orchestration_provider.scale_set != null
+ }
+}
+
+module "orchestration_scale_set" {
+ source = "../orchestration-providers/scale-set"
+ count = length(local.scale_set_runner_configs) > 0 && local.primary_app_installation_id != null ? 1 : 0
+
+ prefix = var.prefix
+ log_level = var.global_config_observability.logs.level
+ runner_configs = local.scale_set_runner_configs
+
+ 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 6b7632ffc6..2ec43c8c56 100644
--- a/modules/multi-runner/outputs.tf
+++ b/modules/multi-runner/outputs.tf
@@ -20,7 +20,6 @@ output "runners_map" {
}
}
}
-
output "runners_map_v2" {
value = { for runner_key, runner in module.runner_configs : runner_key => {
runner = runner.runner
@@ -33,6 +32,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 = module.orchestration_scale_set[0].cluster
+ controller_groups = module.orchestration_scale_set[0].controller_groups
+ reconciler_config_parameters = module.orchestration_scale_set[0].reconciler_config_parameters
+ resolved_container_image = module.orchestration_scale_set[0].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
diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf
index 0f57020571..c969bc6881 100644
--- a/modules/multi-runner/queues.tf
+++ b/modules/multi-runner/queues.tf
@@ -26,8 +26,15 @@ data "aws_iam_policy_document" "deny_insecure_transport" {
}
}
+locals {
+ webhook_queue_configs = {
+ for config, values in local.effective_config.multi_runner_config : config => values
+ if values.orchestration_provider.webhook != null
+ }
+}
+
resource "aws_sqs_queue" "queued_builds" {
- for_each = local.effective_config.multi_runner_config
+ for_each = local.webhook_queue_configs
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 +57,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_queue_configs
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_queue_configs : config => values
if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled
}
name = "${var.prefix}-${each.key}-queued-builds_dead_letter"
@@ -74,7 +81,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_queue_configs : 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 55c850eb84..b1d504442a 100644
--- a/modules/multi-runner/runners.experimental.tf
+++ b/modules/multi-runner/runners.experimental.tf
@@ -37,6 +37,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/config-resolution.tftest.hcl b/modules/multi-runner/tests/config-resolution.tftest.hcl
index 40aa71a426..ff61de79f9 100644
--- a/modules/multi-runner/tests/config-resolution.tftest.hcl
+++ b/modules/multi-runner/tests/config-resolution.tftest.hcl
@@ -65,9 +65,10 @@ variables {
global_config_github = {
app = {
- key_base64 = "experimental-app-key"
- id = "experimental-app-id"
- webhook_secret = "experimental-webhook-secret"
+ key_base64 = "experimental-app-key"
+ id = "experimental-app-id"
+ installation_id = "experimental-app-installation"
+ webhook_secret = "experimental-webhook-secret"
}
}
@@ -392,6 +393,8 @@ run "v2_inputs_resolve_lane_over_global" {
&& keys(module.runner_configs) == ["lane"]
&& length(output.runners_map) == 0
&& keys(output.runners_map_v2) == ["lane"]
+ && keys(aws_sqs_queue.queued_builds) == ["lane"]
+ && keys(aws_sqs_queue_policy.build_queue_policy) == ["lane"]
)
error_message = "Experimental v2 configurations must route through module.runner_configs and skip the legacy runners module."
}
@@ -513,3 +516,393 @@ run "v2_inputs_reject_legacy_runner_config" {
}
}
}
+
+run "scale_set_only_lane_omits_webhook_queues" {
+ command = plan
+
+ variables {
+ experimental_features = ["multi-runner-v2"]
+
+ global_config_github = {
+ app = {
+ key_base64 = "experimental-app-key"
+ id = "experimental-app-id"
+ installation_id = "experimental-app-installation"
+ webhook_secret = "experimental-webhook-secret"
+ }
+ runner_owner = "example"
+ runner_registration_level = "organization"
+ }
+
+ global_config_orchestration_provider = {
+ webhook = {
+ eventbridge = {
+ enabled = false
+ }
+ lambda = {
+ artifact = {
+ s3 = {
+ key = "scale-runners.zip"
+ }
+ }
+ webhook = {
+ artifact = {
+ s3 = {
+ key = "scale-webhook.zip"
+ }
+ }
+ }
+ }
+ }
+ scale_set = {
+ network = {
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set"]
+ }
+ }
+ }
+
+ multi_runner_config = {
+ scale = {
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ }
+ orchestration_provider = {
+ webhook = null
+ scale_set = {
+ name = "scale-only"
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ local.resolved_config.multi_runner_config["scale"].orchestration_provider.webhook == null
+ && local.resolved_config.multi_runner_config["scale"].orchestration_provider.scale_set.name == "scale-only"
+ && keys(aws_sqs_queue.queued_builds) == []
+ && keys(aws_sqs_queue_policy.build_queue_policy) == []
+ && keys(aws_sqs_queue.queued_builds_dlq) == []
+ && keys(aws_sqs_queue_policy.build_queue_dlq_policy) == []
+ && length(module.orchestration_scale_set) == 1
+ )
+ error_message = "A scale-set-only lane must not create or access webhook SQS resources."
+ }
+}
+
+run "mixed_webhook_and_scale_set_lanes_create_webhook_queues_only_for_webhook" {
+ command = plan
+
+ variables {
+ experimental_features = ["multi-runner-v2"]
+
+ global_config_github = {
+ app = {
+ key_base64 = "experimental-app-key"
+ id = "experimental-app-id"
+ installation_id = "experimental-app-installation"
+ webhook_secret = "experimental-webhook-secret"
+ }
+ runner_owner = "example"
+ runner_registration_level = "organization"
+ }
+
+ global_config_orchestration_provider = {
+ webhook = {
+ eventbridge = {
+ enabled = false
+ }
+ lambda = {
+ artifact = {
+ s3 = {
+ key = "mixed-runners.zip"
+ }
+ }
+ webhook = {
+ artifact = {
+ s3 = {
+ key = "mixed-webhook.zip"
+ }
+ }
+ }
+ }
+ }
+ scale_set = {
+ network = {
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set"]
+ }
+ }
+ }
+
+ multi_runner_config = {
+ webhook = {
+ orchestration_provider = {
+ webhook = {
+ matcherConfig = {
+ labelMatchers = [["self-hosted", "linux", "x64"]]
+ }
+ }
+ scale_set = null
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ vpc_id = "vpc-webhook"
+ subnet_ids = ["subnet-webhook"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
+ }
+ }
+ }
+ scale = {
+ orchestration_provider = {
+ webhook = null
+ scale_set = {
+ name = "scale-mixed"
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ keys(aws_sqs_queue.queued_builds) == ["webhook"]
+ && keys(aws_sqs_queue_policy.build_queue_policy) == ["webhook"]
+ && keys(module.runner_configs) == ["scale", "webhook"]
+ )
+ error_message = "Mixed provider lanes must create webhook queues only for the webhook lane while routing both lanes through v2 runner configs."
+ }
+}
+
+run "scale_set_lane_requires_owner_for_non_enterprise_registration" {
+ command = plan
+
+ plan_options {
+ target = [terraform_data.validate_v2]
+ }
+
+ variables {
+ experimental_features = ["multi-runner-v2"]
+
+ global_config_github = {
+ app = {
+ key_base64 = "experimental-app-key"
+ id = "experimental-app-id"
+ webhook_secret = "experimental-webhook-secret"
+ }
+ runner_registration_level = "organization"
+ }
+
+ multi_runner_config = {
+ scale = {
+ orchestration_provider = {
+ webhook = null
+ scale_set = {
+ name = "scale-missing-owner"
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set"]
+ }
+ }
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_v2]
+}
+
+run "scale_set_lane_requires_installation_id" {
+ command = plan
+
+ variables {
+ experimental_features = ["multi-runner-v2"]
+
+ global_config_github = {
+ app = {
+ key_base64 = "experimental-app-key"
+ id = "experimental-app-id"
+ webhook_secret = "experimental-webhook-secret"
+ }
+ runner_owner = "example"
+ runner_registration_level = "organization"
+ }
+
+ multi_runner_config = {
+ scale = {
+ runner = {
+ os = "linux"
+ architecture = "x64"
+ }
+ orchestration_provider = {
+ webhook = null
+ scale_set = {
+ name = "scale-missing-installation"
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set"]
+ }
+ }
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_v2]
+}
+
+run "scale_set_queue_for_each_keys_are_plan_known" {
+ command = plan
+
+ plan_options {
+ target = [aws_sqs_queue.queued_builds, aws_sqs_queue.queued_builds_dlq]
+ }
+
+ variables {
+ experimental_features = ["multi-runner-v2"]
+
+ global_config_orchestration_provider = {
+ scale_set = {
+ network = {
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set"]
+ }
+ }
+ }
+
+ multi_runner_config = {
+ scale = {
+ orchestration_provider = {
+ webhook = null
+ scale_set = {
+ name = "scale-plan-known"
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ vpc_id = "vpc-scale-set"
+ subnet_ids = ["subnet-scale-set"]
+ binaries_syncer = {
+ enabled = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ assert {
+ condition = (
+ keys(aws_sqs_queue.queued_builds) == []
+ && keys(aws_sqs_queue.queued_builds_dlq) == []
+ )
+ error_message = "Webhook queue for_each keys must be known and empty for a scale-set-only plan."
+ }
+}
+
+run "v2_lane_requires_exactly_one_orchestration_provider" {
+ command = plan
+
+ plan_options {
+ target = [terraform_data.validate_v2]
+ }
+
+ variables {
+ experimental_features = ["multi-runner-v2"]
+
+ multi_runner_config = {
+ missing = {
+ orchestration_provider = {}
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ vpc_id = "vpc-missing-provider"
+ subnet_ids = ["subnet-missing-provider"]
+ }
+ }
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_v2]
+}
+
+run "v2_lane_rejects_multiple_orchestration_providers" {
+ command = plan
+
+ plan_options {
+ target = [terraform_data.validate_v2]
+ }
+
+ variables {
+ experimental_features = ["multi-runner-v2"]
+
+ multi_runner_config = {
+ multiple = {
+ orchestration_provider = {
+ webhook = {}
+ scale_set = {
+ name = "multiple-providers"
+ }
+ }
+ compute_provider = {
+ aws = {
+ ec2 = {
+ instance_types = ["m5.large"]
+ vpc_id = "vpc-multiple-providers"
+ subnet_ids = ["subnet-multiple-providers"]
+ }
+ }
+ }
+ }
+ }
+ }
+
+ expect_failures = [terraform_data.validate_v2]
+}
diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf
index 257d479b56..dafb757a9f 100644
--- a/modules/multi-runner/validations.tf
+++ b/modules/multi-runner/validations.tf
@@ -80,14 +80,51 @@ 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(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 provider, EC2 instance_types, vpc_id, and at least one subnet."
+ error_message = "Each experimental v2 runner lane requires the supported aws.ec2 compute provider with instance_types, vpc_id, and at least one subnet."
}
+
+ precondition {
+ condition = alltrue([
+ for config in local.resolved_config.multi_runner_config : (
+ try(config.orchestration_provider.webhook != null, false) !=
+ try(config.orchestration_provider.scale_set != null, false)
+ )
+ ])
+ error_message = "Each experimental v2 runner lane requires exactly one orchestration provider: webhook or scale_set."
+ }
+
+ precondition {
+ condition = alltrue([
+ for config in local.resolved_config.multi_runner_config : (
+ try(config.orchestration_provider.scale_set, null) == null ? true : (
+ contains([
+ "organization",
+ "repository",
+ ], try(var.global_config_github.runner_registration_level, null)) &&
+ try(var.global_config_github.runner_owner, null) != null
+ )
+ )
+ ])
+ error_message = "Scale-set lanes require global_config_github.runner_registration_level to be organization or repository; runner_owner must be set for organization and repository registration."
+ }
+
+ precondition {
+ condition = alltrue([
+ for config in local.resolved_config.multi_runner_config : (
+ try(config.orchestration_provider.scale_set, null) == null ? true : (
+ try(var.global_config_github.app.installation_id, null) != null ||
+ try(var.global_config_github.app.installation_id_ssm, null) != null
+ )
+ )
+ ])
+ error_message = "Scale-set lanes require global_config_github.app.installation_id or global_config_github.app.installation_id_ssm."
+ }
+
}
}
diff --git a/modules/multi-runner/variables.experimental.github.tf b/modules/multi-runner/variables.experimental.github.tf
index 6a783f7d59..79ea3a65cf 100644
--- a/modules/multi-runner/variables.experimental.github.tf
+++ b/modules/multi-runner/variables.experimental.github.tf
@@ -13,6 +13,10 @@ variable "global_config_github" {
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."
+ installation_id: "GitHub App installation ID for the primary scale-set installation."
+ installation_id_ssm: "SSM parameter containing the primary GitHub App installation ID."
+ installation_id_ssm.arn: "ARN of the SSM parameter containing the primary GitHub App installation ID."
+ installation_id_ssm.name: "Name of the SSM parameter containing the primary GitHub App installation 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."
@@ -33,6 +37,8 @@ variable "global_config_github" {
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."
+ runner_owner: "GitHub organization or owner/repository path for organization- or repository-level scale-set registration."
+ runner_registration_level: "GitHub scale-set registration scope: organization or repository."
user_agent: "User-Agent value sent with GitHub API requests."
}
EOT
@@ -48,6 +54,11 @@ variable "global_config_github" {
arn = string
name = string
}))
+ installation_id = optional(string)
+ installation_id_ssm = optional(object({
+ arn = string
+ name = string
+ }))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
@@ -66,7 +77,9 @@ variable "global_config_github" {
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
- user_agent = optional(string, "github-aws-runners")
+ runner_owner = optional(string, null)
+ runner_registration_level = optional(string, "organization")
+ user_agent = optional(string, "github-aws-runners")
})
default = {}
}
diff --git a/modules/multi-runner/variables.experimental.orchestration-provider.tf b/modules/multi-runner/variables.experimental.orchestration-provider.tf
index 9919d3645e..41a51d6204 100644
--- a/modules/multi-runner/variables.experimental.orchestration-provider.tf
+++ b/modules/multi-runner/variables.experimental.orchestration-provider.tf
@@ -173,6 +173,78 @@ 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)
+ }), {})
+ 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/variables.tf b/modules/multi-runner/variables.tf
index 793e126e4e..bf47576d67 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -19,6 +19,11 @@ variable "github_app" {
arn = string
name = string
}))
+ installation_id = optional(string)
+ installation_id_ssm = optional(object({
+ arn = string
+ name = string
+ }))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
@@ -386,6 +391,14 @@ variable "multi_runner_config" {
}), {})
}), {})
}), null)
+ scale_set = optional(object({
+ name = string
+ runner = optional(object({
+ min_runners = optional(number, 0)
+ max_runners = optional(number, 10)
+ boot_time_in_minutes = optional(number, 10)
+ }), {})
+ }), null)
}), {})
ssm = optional(object({
diff --git a/modules/orchestration-providers/scale-set/README.md b/modules/orchestration-providers/scale-set/README.md
index 495a8cc314..cc7c90eb23 100644
--- a/modules/orchestration-providers/scale-set/README.md
+++ b/modules/orchestration-providers/scale-set/README.md
@@ -218,6 +218,7 @@ No modules.
| [terraform_data.validate_compute_role_policy](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
| [terraform_data.validate_config_store](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
| [terraform_data.validate_contract](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
+| [terraform_data.validate_group_compute_policy](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
| [terraform_data.validate_group_task_policy](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
| [terraform_data.validate_grouping](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
| [terraform_data.validate_runtime](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
diff --git a/modules/orchestration-providers/scale-set/validations.tf b/modules/orchestration-providers/scale-set/validations.tf
index 9553581597..d956e21f2f 100644
--- a/modules/orchestration-providers/scale-set/validations.tf
+++ b/modules/orchestration-providers/scale-set/validations.tf
@@ -423,3 +423,17 @@ resource "terraform_data" "validate_compute_role_policy" {
}
}
}
+
+resource "terraform_data" "validate_group_compute_policy" {
+ for_each = local.controller_groups
+
+ lifecycle {
+ precondition {
+ condition = (
+ floor(length(base64encode(data.aws_iam_policy_document.task_compute[each.key].json)) * 3 / 4) -
+ (endswith(base64encode(data.aws_iam_policy_document.task_compute[each.key].json), "==") ? 2 : endswith(base64encode(data.aws_iam_policy_document.task_compute[each.key].json), "=") ? 1 : 0)
+ ) <= 10240
+ error_message = "Controller group ${each.key} produces a compute-provider task-role policy exceeding AWS's 10240-byte inline role-policy quota. Split the group or reduce provider IAM statements."
+ }
+ }
+}
diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md
index 7c1585dca2..41c0b1a191 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 = map(string)
id = map(string)
additional_apps_manifest = optional(object({
name = string
arn = string
}), null)
additional_app_parameter_arns = optional(list(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_confirmation_seconds = optional(number, 0)
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_confirmation_seconds = optional(number, 0)
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({
name = string
runner = optional(object({
min_runners = optional(number, 0)
max_runners = optional(number, 10)
boot_time_in_minutes = optional(number, 10)
}), {})
}), 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 16238fa6c6..bd804ad846 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..d52230ba5f 100644
--- a/modules/runner-config/outputs.tf
+++ b/modules/runner-config/outputs.tf
@@ -22,13 +22,26 @@ output "pool" {
output "orchestration_provider" {
description = "Resources grouped under the selected runner orchestration provider."
+ value = merge(
+ {
+ webhook = local.orchestration_provider_enabled.webhook ? {
+ scale_up = one(module.orchestration_webhook[*].scale_up)
+ scale_down = one(module.orchestration_webhook[*].scale_down)
+ pool = one(module.orchestration_webhook[*].pool)
+ job_retry = one(module.orchestration_webhook[*].job_retry)
+ } : null
+ },
+ local.orchestration_provider_enabled.scale_set ? {
+ scale_set = {}
+ } : {},
+ )
+}
+
+output "compute_provider_contract" {
+ description = "Provider-neutral compute-provider capabilities consumed by topology-level orchestration."
value = {
- webhook = local.orchestration_provider_enabled.webhook ? {
- scale_up = one(module.orchestration_webhook[*].scale_up)
- scale_down = one(module.orchestration_webhook[*].scale_down)
- pool = one(module.orchestration_webhook[*].pool)
- job_retry = one(module.orchestration_webhook[*].job_retry)
- } : null
+ type = local.provider_contract.type
+ capabilities = local.provider_contract.capabilities
}
}
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 44fa3525fd..d51136c85f 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`.
@@ -137,6 +138,14 @@ variable "orchestration_provider" {
}), {})
}), {})
}), null)
+ scale_set = optional(object({
+ name = string
+ runner = optional(object({
+ min_runners = optional(number, 0)
+ max_runners = optional(number, 10)
+ boot_time_in_minutes = optional(number, 10)
+ }), {})
+ }), null)
})
nullable = false
diff --git a/modules/ssm/README.md b/modules/ssm/README.md
index e35c340071..9a3f98a379 100644
--- a/modules/ssm/README.md
+++ b/modules/ssm/README.md
@@ -31,6 +31,7 @@ No modules.
| [aws_ssm_parameter.additional_github_app_key_base64](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
| [aws_ssm_parameter.additional_github_apps_manifest](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
| [aws_ssm_parameter.github_app_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
+| [aws_ssm_parameter.github_app_installation_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
| [aws_ssm_parameter.github_app_key_base64](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
| [aws_ssm_parameter.github_app_webhook_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
@@ -39,7 +40,7 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for distributing API rate limit usage. | 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 }))
})) | `[]` | no |
-| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app. 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
}))
}) | n/a | yes |
+| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app. 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
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}) | n/a | yes |
| [kms\_key\_arn](#input\_kms\_key\_arn) | Optional CMK Key ARN to be used for Parameter Store. | `string` | `null` | no |
| [path\_prefix](#input\_path\_prefix) | The path prefix used for naming resources | `string` | n/a | yes |
| [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no |
diff --git a/modules/ssm/outputs.tf b/modules/ssm/outputs.tf
index e1afaf990e..f37b28d939 100644
--- a/modules/ssm/outputs.tf
+++ b/modules/ssm/outputs.tf
@@ -8,6 +8,10 @@ output "parameters" {
name = var.github_app.key_base64_ssm != null ? var.github_app.key_base64_ssm.name : aws_ssm_parameter.github_app_key_base64[0].name
arn = var.github_app.key_base64_ssm != null ? var.github_app.key_base64_ssm.arn : aws_ssm_parameter.github_app_key_base64[0].arn
}
+ github_app_installation_id = var.github_app.installation_id_ssm != null || var.github_app.installation_id != null ? {
+ name = var.github_app.installation_id_ssm != null ? var.github_app.installation_id_ssm.name : aws_ssm_parameter.github_app_installation_id[0].name
+ arn = var.github_app.installation_id_ssm != null ? var.github_app.installation_id_ssm.arn : aws_ssm_parameter.github_app_installation_id[0].arn
+ } : null
github_app_webhook_secret = {
name = var.github_app.webhook_secret_ssm != null ? var.github_app.webhook_secret_ssm.name : aws_ssm_parameter.github_app_webhook_secret[0].name
arn = var.github_app.webhook_secret_ssm != null ? var.github_app.webhook_secret_ssm.arn : aws_ssm_parameter.github_app_webhook_secret[0].arn
diff --git a/modules/ssm/ssm.tf b/modules/ssm/ssm.tf
index 9467a136e5..ab8a406f2d 100644
--- a/modules/ssm/ssm.tf
+++ b/modules/ssm/ssm.tf
@@ -16,6 +16,15 @@ resource "aws_ssm_parameter" "github_app_key_base64" {
tags = var.tags
}
+resource "aws_ssm_parameter" "github_app_installation_id" {
+ count = var.github_app.installation_id_ssm != null || var.github_app.installation_id == null ? 0 : 1
+ name = "${var.path_prefix}/github_app_installation_id"
+ type = "SecureString"
+ value = var.github_app.installation_id
+ key_id = local.kms_key_arn
+ tags = var.tags
+}
+
resource "aws_ssm_parameter" "github_app_webhook_secret" {
count = var.github_app.webhook_secret_ssm != null ? 0 : 1
name = "${var.path_prefix}/github_app_webhook_secret"
diff --git a/modules/ssm/variables.tf b/modules/ssm/variables.tf
index d7387ecc30..d1c0f41f36 100644
--- a/modules/ssm/variables.tf
+++ b/modules/ssm/variables.tf
@@ -17,6 +17,11 @@ variable "github_app" {
arn = string
name = string
}))
+ installation_id = optional(string)
+ installation_id_ssm = optional(object({
+ arn = string
+ name = string
+ }))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string