diff --git a/README.md b/README.md index 88f7fbefcb..d4ee97d239 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ This [Terraform](https://www.terraform.io/) module creates the required infrastr - Tailored software, hardware and network configuration: Bring your own AMI, define the instance types and subnets to use. - OS support: Linux (x64/arm64) and Windows - Multi-Runner: Create multiple runner configurations with a single deployment +- [Multiple organizations](docs/multi-org.md): Opt-in organization-aware registration, scheduled pools, and cleanup - GitHub cloud, GitHub Cloud with Data Residency and GitHub Enterprise Server (GHES) support. - Org and repo level runners. enterprise level runners are not supported (yet). @@ -128,6 +129,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [enable\_jit\_config](#input\_enable\_jit\_config) | Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI. | `bool` | `null` | no | | [enable\_job\_queued\_check](#input\_enable\_job\_queued\_check) | Only scale if the job event received by the scale up lambda is in the queued state. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior. | `bool` | `null` | no | | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enables creation of the default managed security group. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | +| [enable\_multi\_org\_runners](#input\_enable\_multi\_org\_runners) | Enable organization-scoped runners across multiple GitHub organizations. Resolves app installations per organization, scopes runner-group caches and idle retention by organization, and enables pool\_config.org. | `bool` | `false` | no | | [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | `false` | no | | [enable\_runner\_bidirectional\_label\_match](#input\_enable\_runner\_bidirectional\_label\_match) | If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `enable_runner_workflow_job_labels_check_all` which only checks that workflow labels are a subset of runner labels. When false, if __any__ label matches it will trigger the webhook. | `bool` | `false` | no | | [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no | @@ -171,7 +173,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [metrics](#input\_metrics) | Configuration for metrics created by the module, by default 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 |
| [minimum\_running\_time\_in\_minutes](#input\_minimum\_running\_time\_in\_minutes) | The time an ec2 action runner should be running at minimum before terminated, if not busy. | `number` | `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\_config](#input\_pool\_config) | The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for weekdays to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC). | list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})) | `[]` | no |
+| [pool\_config](#input\_pool\_config) | The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for weekdays to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC). With `enable_multi_org_runners`, set `org` per schedule; omitted values use `pool_runner_owner`. | list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
org = optional(string)
size = number
})) | `[]` | no |
| [pool\_include\_busy\_runners](#input\_pool\_include\_busy\_runners) | Include busy runners in the pool calculation. By default busy runners are not included in the pool. | `bool` | `false` | no |
| [pool\_lambda\_memory\_size](#input\_pool\_lambda\_memory\_size) | Memory size limit for scale-up lambda. | `number` | `512` | 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 |
diff --git a/docs/multi-org.md b/docs/multi-org.md
new file mode 100644
index 0000000000..ae92bf4864
--- /dev/null
+++ b/docs/multi-org.md
@@ -0,0 +1,45 @@
+# Multiple organizations
+
+Enable `enable_multi_org_runners = true` to share a control plane across organizations. Runners register in the organization that owns the repository in the webhook. This selects organization-level registration even if `enable_organization_runners` is false. The flag defaults to false, preserving existing registration, installation selection, pool ownership, and scale-down behavior.
+
+Install the GitHub App in each target organization. An enterprise-owned app can be used through its organization installations. The app needs **Self-hosted runners: write** at organization scope for [organization JIT configuration](https://docs.github.com/en/rest/actions/self-hosted-runners#create-configuration-for-a-just-in-time-runner-for-an-organization), along with the existing workflow-job permissions and webhook subscriptions. This mode uses organization runner APIs.
+
+## Scheduled pools
+
+Add `org` to each `pool_config` schedule:
+
+```hcl
+enable_multi_org_runners = true
+enable_ephemeral_runners = true
+
+pool_config = [
+ {
+ org = "org-a"
+ schedule_expression = "cron(0 8 * * ? *)"
+ schedule_expression_timezone = "UTC"
+ size = 2
+ },
+ {
+ org = "org-b"
+ schedule_expression = "cron(0 8 * * ? *)"
+ schedule_expression_timezone = "UTC"
+ size = 5
+ },
+]
+```
+
+An omitted `org` uses `pool_runner_owner`. Multi-org pools must have a valid organization login in one of those fields. Use the organization's login rather than its display name. Multi-org mode normalizes it to lowercase across pools, webhooks, retries, and cleanup. Without the flag, `org` is ignored and the existing default owner is used.
+
+Pool reconciliation lists GitHub runners and compute instances for that organization only. `runners_maximum_count` applies separately to each organization within a runner configuration. Each organization shares that runner configuration's labels, runner-group name, compute settings, and maximum count. A schedule defines a target size, not an additive pool; avoid conflicting schedules for the same organization. Existing scale-up/pool concurrency limits still apply, and maximum checks are not atomic across concurrent invocations.
+
+For the legacy `modules/multi-runner` interface, set `enable_multi_org_runners` and `pool_config` inside the entry's `runner_config`. For the v2 interface and `modules/runner-config`, set `orchestration_provider.webhook.github.multi_org_runners = true` and put the schedules under `orchestration_provider.webhook.lambda.pool.config`. Its default pool owner is `lambda.pool.runner_owner`.
+
+## Installation and runner lifecycle
+
+- Scale-up and job retry reuse the primary app's webhook installation ID. Additional apps, or events without an installation ID, resolve the selected app's installation for the target organization. Every configured app that can be selected must be installed in all target organizations.
+- Pool and scale-down resolve an organization installation with the selected app. Preconfigured global installation IDs are ignored in multi-org mode because they cannot identify installations in several organizations.
+- Runner-group IDs are cached by organization and group name. A group named `Default` in one organization cannot supply another organization's group ID. Existing unscoped entries are not reused in multi-org mode.
+- EC2 already persists the organization in `ghr:Owner` alongside `ghr:Type = Org`. Scale-down, deregistration, and orphan checks use that ownership metadata; no additional tag is required. Capacity lookups include existing mixed-case owner tags, and scale-down groups those tags under the same lowercase organization. EC2 queries retain their environment and runner-type filters, then compare owner tags locally because AWS tag matching is case-sensitive. Other compute providers use the equivalent owner/type fields in their provider contract.
+- Scale-down applies the existing idle configuration independently to each organization. After enabling multi-org on a repository-scoped deployment, legacy repository runners share the same organization allowance as new organization runners. Eviction ordering applies across both types, while cleanup continues to use each runner’s original repository or organization API. Pool sizes do not change scale-down idle settings; these remain separate controls. Orphan checks use the tagged owner's GitHub endpoints, including the final check before termination of a JIT orphan. A GitHub lookup failure does not establish that a runner is an orphan.
+
+This feature does not verify enterprise membership. The organizations available to the GitHub Apps and the existing webhook repository allowlist define the accepted scope. Existing owner tags remain readable when toggling the flag; do not remove an app installation while it still has managed runners to clean up.
diff --git a/lambdas/functions/control-plane/src/github/multi-org.ts b/lambdas/functions/control-plane/src/github/multi-org.ts
new file mode 100644
index 0000000000..5dce76cd9e
--- /dev/null
+++ b/lambdas/functions/control-plane/src/github/multi-org.ts
@@ -0,0 +1,11 @@
+import yn from 'yn';
+
+/** Opt-in organization-scoped installation selection and lifecycle accounting. */
+export function multiOrgEnabled(): boolean {
+ return yn(process.env.ENABLE_MULTI_ORG_RUNNERS, { default: false });
+}
+
+/** Preserve legacy identity while using GitHub's case-insensitive org logins in multi-org mode. */
+export function normalizeOrganization(owner: string): string {
+ return multiOrgEnabled() ? owner.toLowerCase() : owner;
+}
diff --git a/lambdas/functions/control-plane/src/github/octokit.test.ts b/lambdas/functions/control-plane/src/github/octokit.test.ts
index 351ce84159..fbe98b2e0f 100644
--- a/lambdas/functions/control-plane/src/github/octokit.test.ts
+++ b/lambdas/functions/control-plane/src/github/octokit.test.ts
@@ -190,3 +190,30 @@ describe('Test getOctokit stale installation fallback', () => {
expect(createGithubInstallationAuth).toHaveBeenCalledTimes(1);
});
});
+
+describe('multi-org retry authentication', () => {
+ it.each([0, 1])('ignores global installation for app %s', async (appIndex) => {
+ vi.clearAllMocks();
+ vi.stubEnv('ENABLE_MULTI_ORG_RUNNERS', 'true');
+ try {
+ vi.mocked(createGithubAppAuth).mockResolvedValue({ token: 'token', appIndex } as Awaited<
+ ReturnTypeobject({
app = optional(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}), null)
additional_apps = optional(list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
}) | `{}` | no |
| [global\_config\_lambda](#input\_global\_config\_lambda) | Global Lambda configuration shared by all runner lanes.object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}) | `{}` | no |
| [global\_config\_observability](#input\_global\_config\_observability) | Global observability configuration shared by all runner lanes.object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(object({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
}) | `{}` | no |
-| [global\_config\_orchestration\_provider](#input\_global\_config\_orchestration\_provider) | Global orchestration-provider configuration shared by all runner lanes.object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enabled = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
repository_white_list = optional(list(string), [])
}), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_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)
org = 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\_storage\_provider](#input\_global\_config\_storage\_provider) | Global storage-provider configuration shared by all runner lanes.object({
aws = optional(object({
ssm = optional(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 +188,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)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
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
})), [])
ssm_ttl_seconds = optional(object({
tokens = optional(number, null)
}), {})
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({
allowed_keys = optional(list(string), [])
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)
}), {})
storage_provider = optional(object({
aws = optional(object({
ssm = optional(object({
ttl_seconds = optional(object({
tokens = optional(number, null)
}), {})
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)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
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_multi_org_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)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
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)
org = optional(string)
size = number
})), [])
ssm_ttl_seconds = optional(object({
tokens = optional(number, null)
}), {})
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)
multi_org_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({
allowed_keys = optional(list(string), [])
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)
org = 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)
}), {})
storage_provider = optional(object({
aws = optional(object({
ssm = optional(object({
ttl_seconds = optional(object({
tokens = optional(number, null)
}), {})
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)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
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 |
diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf
index 02c8880c69..d2e8280941 100644
--- a/modules/multi-runner/config.experimental.translation.tf
+++ b/modules/multi-runner/config.experimental.translation.tf
@@ -365,6 +365,7 @@ locals {
github = {
organization_runners = v.runner_config.enable_organization_runners
+ multi_org_runners = v.runner_config.enable_multi_org_runners
}
matcherConfig = {
diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf
index 1311ee9dc9..ebc0da6053 100644
--- a/modules/multi-runner/runners.tf
+++ b/modules/multi-runner/runners.tf
@@ -44,6 +44,7 @@ module "runners" {
enable_on_demand_failover_for_errors = each.value.compute_provider.aws.ec2.on_demand_failover_for_errors
scale_errors = each.value.compute_provider.aws.ec2.scale_errors
enable_organization_runners = each.value.orchestration_provider.webhook.github.organization_runners
+ enable_multi_org_runners = each.value.orchestration_provider.webhook.github.multi_org_runners
enable_ephemeral_runners = each.value.orchestration_provider.webhook.runner.ephemeral
enable_jit_config = each.value.orchestration_provider.webhook.runner.jit_config_enabled
enable_job_queued_check = each.value.orchestration_provider.webhook.lambda.scale.up.job_queued_check_enabled
diff --git a/modules/multi-runner/tests/config-effective.tftest.hcl b/modules/multi-runner/tests/config-effective.tftest.hcl
index 197f0983a9..fc4d0c7874 100644
--- a/modules/multi-runner/tests/config-effective.tftest.hcl
+++ b/modules/multi-runner/tests/config-effective.tftest.hcl
@@ -94,10 +94,12 @@ run "v1_effective_config_contains_derived_runner_labels" {
multi_runner_config = {
stable = {
runner_config = {
- runner_os = "linux"
- runner_architecture = "x64"
- instance_types = ["m5.large"]
- runners_maximum_count = 1
+ runner_os = "linux"
+ runner_architecture = "x64"
+ instance_types = ["m5.large"]
+ runners_maximum_count = 1
+ enable_multi_org_runners = true
+ pool_config = [{ schedule_expression = "cron(0 8 * * ? *)", size = 1, org = "org-a" }]
}
matcherConfig = {
labelMatchers = [["stable-label"]]
@@ -115,6 +117,15 @@ run "v1_effective_config_contains_derived_runner_labels" {
])
error_message = "The effective v1 configuration must contain the translated runner labels."
}
+
+ assert {
+ condition = (
+ local.effective_config.multi_runner_config["stable"].orchestration_provider.webhook.github.multi_org_runners &&
+ local.effective_config.multi_runner_config["stable"].orchestration_provider.webhook.lambda.pool.config[0].org == "org-a" &&
+ module.runners["stable"].lambda_scale_up.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true"
+ )
+ error_message = "Legacy multi-org inputs must survive translation into the resource configuration."
+ }
}
run "v2_effective_config_contains_derived_values" {
@@ -243,6 +254,8 @@ run "v2_effective_config_contains_derived_values" {
}
orchestration_provider = {
webhook = {
+ github = { multi_org_runners = true }
+ lambda = { pool = { config = [{ schedule_expression = "cron(0 8 * * ? *)", size = 1, org = "org-b" }] } }
matcherConfig = {
labelMatchers = [["matcher-label"]]
}
@@ -264,6 +277,9 @@ run "v2_effective_config_contains_derived_values" {
assert {
condition = (
+ local.effective_config.multi_runner_config["lane"].orchestration_provider.webhook.github.multi_org_runners &&
+ local.effective_config.multi_runner_config["lane"].orchestration_provider.webhook.lambda.pool.config[0].org == "org-b" &&
+ module.runner_configs["lane"].scale_up.lambda.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true" &&
toset(local.effective_config.multi_runner_config["lane"].runner.labels) == toset([
"lane-label",
"linux",
diff --git a/modules/multi-runner/variables.experimental.orchestration-provider.tf b/modules/multi-runner/variables.experimental.orchestration-provider.tf
index 9919d3645e..09571ebdd2 100644
--- a/modules/multi-runner/variables.experimental.orchestration-provider.tf
+++ b/modules/multi-runner/variables.experimental.orchestration-provider.tf
@@ -143,6 +143,7 @@ variable "global_config_orchestration_provider" {
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
+ org = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf
index 8c4bde3668..9cd39fe360 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -126,6 +126,7 @@ variable "multi_runner_config" {
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
+ enable_multi_org_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
@@ -249,6 +250,7 @@ variable "multi_runner_config" {
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
+ org = optional(string)
size = number
})), [])
ssm_ttl_seconds = optional(object({
@@ -341,6 +343,7 @@ variable "multi_runner_config" {
}), {})
github = optional(object({
organization_runners = optional(bool, false)
+ multi_org_runners = optional(bool, false)
}), {})
matcherConfig = optional(object({
labelMatchers = list(list(string))
@@ -403,6 +406,7 @@ variable "multi_runner_config" {
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
+ org = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md
index a03e8b4f1d..a550bae9a7 100644
--- a/modules/orchestration-providers/webhook/README.md
+++ b/modules/orchestration-providers/webhook/README.md
@@ -39,7 +39,7 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no |
-| [config](#input\_config) | Provider-owned webhook values supplied from `orchestration_provider.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, 0)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
}) | n/a | yes |
+| [config](#input\_config) | Provider-owned webhook values supplied from `orchestration_provider.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
multi_org_runners = optional(bool, false)
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, 0)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
org = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
}) | n/a | yes |
| [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. | 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 = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
}) | n/a | yes |
| [lambda](#input\_lambda) | Common Lambda substrate. Only the shared artifact bucket crosses this boundary; the webhook provider owns its archive key, version, and local zip selection. | object({
artifact = object({
s3 = object({
bucket = optional(string, null)
})
})
runtime = string
architecture = string
subnet_ids = list(string)
security_group_ids = list(string)
tags = optional(map(string), {})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
}) | n/a | yes |
| [observability](#input\_observability) | Common logging, tracing, and metrics configuration consumed by webhook controls. | object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
tags = optional(map(string), {})
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
job_retry = object({
enabled = bool
})
})
})
}) | n/a | yes |
diff --git a/modules/orchestration-providers/webhook/job-retry/README.md b/modules/orchestration-providers/webhook/job-retry/README.md
index 03836db4d8..90cdb4edf5 100644
--- a/modules/orchestration-providers/webhook/job-retry/README.md
+++ b/modules/orchestration-providers/webhook/job-retry/README.md
@@ -52,7 +52,7 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
-| [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-config.object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
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), [])
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
encryption = object({
sqs_managed_sse_enabled = bool
kms_master_key_id = optional(string, null)
kms_data_key_reuse_period_seconds = optional(number, null)
})
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
job_retry = object({
enabled = bool
})
})
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
queue = map(string)
event_source_mapping = map(string)
})
}) | n/a | yes |
+| [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-config.object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
multi_org_runners = optional(bool, false)
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
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), [])
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
encryption = object({
sqs_managed_sse_enabled = bool
kms_master_key_id = optional(string, null)
kms_data_key_reuse_period_seconds = optional(number, null)
})
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
job_retry = object({
enabled = bool
})
})
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
queue = map(string)
event_source_mapping = map(string)
})
}) | n/a | yes |
| [storage\_provider](#input\_storage\_provider) | Resolved storage-provider configuration and capability used by the job-retry Lambda. | object({
aws = object({
ssm = object({
kms_key_id = optional(string, null)
})
})
environment_variables = optional(map(string), {})
iam_policy_json = optional(string, null)
}) | n/a | yes |
## Outputs
diff --git a/modules/orchestration-providers/webhook/job-retry/job-retry.tf b/modules/orchestration-providers/webhook/job-retry/job-retry.tf
index 725c53d3d8..5ed77e59d8 100644
--- a/modules/orchestration-providers/webhook/job-retry/job-retry.tf
+++ b/modules/orchestration-providers/webhook/job-retry/job-retry.tf
@@ -33,6 +33,7 @@ locals {
PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name
PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name
PARAMETER_GITHUB_APPS_MANIFEST_NAME = var.config.github.app_parameters.additional_apps_manifest != null ? var.config.github.app_parameters.additional_apps_manifest.name : ""
+ ENABLE_MULTI_ORG_RUNNERS = var.config.github.multi_org_runners
}
environment_variables = merge(
diff --git a/modules/orchestration-providers/webhook/job-retry/variables.tf b/modules/orchestration-providers/webhook/job-retry/variables.tf
index 863324152b..73c492ee78 100644
--- a/modules/orchestration-providers/webhook/job-retry/variables.tf
+++ b/modules/orchestration-providers/webhook/job-retry/variables.tf
@@ -21,6 +21,7 @@ variable "config" {
- `lambda.role.principals`: Extra principals allowed to assume the Lambda role, for example during local testing.
- `runner.name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `github.organization_runners`: Enables organization runners.
+ - `github.multi_org_runners`: Opt-in multi-organization runners. Overrides repository scope, resolves installations per organization, and scopes runner-group caching and idle retention to each organization. Defaults to false.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
@@ -80,6 +81,7 @@ variable "config" {
})
github = object({
organization_runners = bool
+ multi_org_runners = optional(bool, false)
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
diff --git a/modules/orchestration-providers/webhook/pool.tf b/modules/orchestration-providers/webhook/pool.tf
index 6fe8d93913..06b3204870 100644
--- a/modules/orchestration-providers/webhook/pool.tf
+++ b/modules/orchestration-providers/webhook/pool.tf
@@ -3,7 +3,8 @@ module "pool" {
source = "./pool"
config = {
- prefix = local.resolved_config.prefix
+ enable_multi_org_runners = local.resolved_config.github.multi_org_runners
+ prefix = local.resolved_config.prefix
ghes = {
ssl_verify = local.resolved_config.github.enterprise_server.ssl_verify
url = local.resolved_config.github.enterprise_server.url
diff --git a/modules/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md
index a6f7a0e086..688ded4c01 100644
--- a/modules/orchestration-providers/webhook/pool/README.md
+++ b/modules/orchestration-providers/webhook/pool/README.md
@@ -54,7 +54,7 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no |
-| [config](#input\_config) | Configuration passed from the webhook orchestration provider to the pool Lambda and scheduler.object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_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), [])
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
boot_time_in_minutes = number
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
role_path = string
lambda_tags = map(string)
log_group_tags = optional(map(string), {})
user_agent = string
}) | n/a | yes |
+| [config](#input\_config) | Configuration passed from the webhook orchestration provider to the pool Lambda and scheduler.object({
enable_multi_org_runners = optional(bool, false)
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_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), [])
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
boot_time_in_minutes = number
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
org = optional(string)
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
role_path = string
lambda_tags = map(string)
log_group_tags = optional(map(string), {})
user_agent = string
}) | n/a | yes |
| [runner\_provider](#input\_runner\_provider) | Compute provider integration used by the pool Lambda.object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
}) | n/a | yes |
| [storage\_provider](#input\_storage\_provider) | Resolved storage-provider configuration and capability used by the pool Lambda. | object({
aws = object({
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
kms_key_id = optional(string, null)
parameter_store_tags = string
})
})
environment_variables = optional(map(string), {})
iam_policy_json = optional(string, null)
}) | n/a | yes |
| [tracing\_config](#input\_tracing\_config) | Tracing configuration for the pool Lambda.object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}) | `{}` | no |
diff --git a/modules/orchestration-providers/webhook/pool/pool.tf b/modules/orchestration-providers/webhook/pool/pool.tf
index 1ff7249fd7..1151f1d4f7 100644
--- a/modules/orchestration-providers/webhook/pool/pool.tf
+++ b/modules/orchestration-providers/webhook/pool/pool.tf
@@ -27,6 +27,7 @@ locals {
POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests
POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error
INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners
+ ENABLE_MULTI_ORG_RUNNERS = var.config.enable_multi_org_runners
}
ssm_environment_variables = {
@@ -237,9 +238,9 @@ resource "aws_scheduler_schedule" "pool" {
target {
arn = aws_lambda_function.pool.arn
role_arn = aws_iam_role.scheduler.arn
- input = jsonencode({
+ input = jsonencode(merge({
poolSize = each.value.size
type = var.runner_provider.type
- })
+ }, var.config.enable_multi_org_runners ? { org = each.value.org } : {}))
}
}
diff --git a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl
index 25fa6c8a14..4bf28aa602 100644
--- a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl
+++ b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl
@@ -293,3 +293,176 @@ run "requires_enabled_compute_provider_managed_policy_arn" {
expect_failures = [terraform_data.validate_config]
}
+
+
+run "multi_org_pool_schedules" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [
+ { schedule_expression = "cron(0 8 * * ? *)", schedule_expression_timezone = "UTC", size = 2, org = "org-a" },
+ { schedule_expression = "cron(0 8 * * ? *)", schedule_expression_timezone = "UTC", size = 5, org = "org-b" },
+ ]
+ })
+ }
+ assert {
+ condition = (
+ aws_lambda_function.pool.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true" &&
+ jsondecode(aws_scheduler_schedule.pool["0"].target[0].input).org == "org-a" &&
+ jsondecode(aws_scheduler_schedule.pool["1"].target[0].input).org == "org-b" &&
+ jsondecode(aws_scheduler_schedule.pool["1"].target[0].input).poolSize == 5
+ )
+ error_message = "Each pool schedule must preserve its organization and capacity."
+ }
+}
+
+run "legacy_pool_payload_is_unchanged" {
+ command = plan
+ assert {
+ condition = (
+ aws_lambda_function.pool.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "false" &&
+ !contains(keys(jsondecode(aws_scheduler_schedule.pool["0"].target[0].input)), "org")
+ )
+ error_message = "Legacy pool payloads must not include organization overrides."
+ }
+}
+
+run "multi_org_pool_requires_an_owner" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ runner = merge(var.config.runner, { pool_owner = null })
+ })
+ }
+ expect_failures = [var.config]
+}
+
+
+run "multi_org_pool_rejects_empty_override" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [{ schedule_expression = "cron(0 8 * * ? *)", schedule_expression_timezone = "UTC", size = 2, org = "" }]
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_trailing_hyphen_override" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [merge(var.config.pool[0], { org = "org-" })]
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_trailing_hyphen_default" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ runner = merge(var.config.runner, { pool_owner = "org-" })
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_repeated_hyphen_override" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [merge(var.config.pool[0], { org = "org--name" })]
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_repeated_hyphen_default" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ runner = merge(var.config.runner, { pool_owner = "org--name" })
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_leading_hyphen_override" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [merge(var.config.pool[0], { org = "-org" })]
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_leading_hyphen_default" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ runner = merge(var.config.runner, { pool_owner = "-org" })
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_too_long_override" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [merge(var.config.pool[0], { org = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" })]
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_too_long_default" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ runner = merge(var.config.runner, { pool_owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" })
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "accepts_valid_logins_and_length_boundary" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [for org in ["a", "Org-1", "org-a-b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1"] : merge(var.config.pool[0], { org = org })]
+ })
+ }
+ assert {
+ condition = length(aws_scheduler_schedule.pool) == 5
+ error_message = "Valid logins including the 39-character boundary must be accepted."
+ }
+}
+
+run "preserves_disabled_mode_login_handling" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = false
+ runner = merge(var.config.runner, { pool_owner = "org--name" })
+ })
+ }
+ assert {
+ condition = aws_lambda_function.pool.environment[0].variables["RUNNER_OWNER"] == "org--name"
+ error_message = "Stricter validation must remain gated by multi-org mode."
+ }
+}
diff --git a/modules/orchestration-providers/webhook/pool/variables.tf b/modules/orchestration-providers/webhook/pool/variables.tf
index 8ccb8fa16d..51096500ae 100644
--- a/modules/orchestration-providers/webhook/pool/variables.tf
+++ b/modules/orchestration-providers/webhook/pool/variables.tf
@@ -51,6 +51,7 @@ variable "config" {
- `user_agent`: User-Agent header used for GitHub API requests.
EOF
type = object({
+ enable_multi_org_runners = optional(bool, false)
lambda = object({
log_level = string
logging_retention_in_days = number
@@ -101,6 +102,7 @@ variable "config" {
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
+ org = optional(string)
size = number
}))
include_busy_runners = bool
@@ -110,6 +112,17 @@ variable "config" {
log_group_tags = optional(map(string), {})
user_agent = string
})
+
+ validation {
+ condition = !var.config.enable_multi_org_runners || alltrue([
+ for pool in var.config.pool : try(
+ length(pool.org == null ? var.config.runner.pool_owner : pool.org) <= 39 &&
+ can(regex("^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$", pool.org == null ? var.config.runner.pool_owner : pool.org)),
+ false
+ )
+ ])
+ error_message = "Multi-org pools require an organization login in each schedule's org or the default pool owner: 1-39 alphanumeric characters or single hyphens, with no leading or trailing hyphen."
+ }
}
variable "runner_provider" {
diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md
index 87e3c1cefb..4a0806739d 100644
--- a/modules/orchestration-providers/webhook/scale-runners/README.md
+++ b/modules/orchestration-providers/webhook/scale-runners/README.md
@@ -67,7 +67,7 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM policy ARNs. | `string` | `"aws"` | no |
-| [config](#input\_config) | Provider-neutral scale-up and scale-down configuration assembled by runner-config.object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
boot_time_in_minutes = number
maximum_count = number
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
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), [])
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
})
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = bool
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
event_source_mapping = map(string)
})
})
scale_down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, 0)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
job_retry = object({
enabled = bool
max_attempts = number
delay_in_seconds = number
delay_backoff = number
queue = optional(object({
arn = string
url = string
}), null)
})
}) | n/a | yes |
+| [config](#input\_config) | Provider-neutral scale-up and scale-down configuration assembled by runner-config.object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
boot_time_in_minutes = number
maximum_count = number
})
github = object({
organization_runners = bool
multi_org_runners = optional(bool, false)
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
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), [])
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
})
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = bool
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
event_source_mapping = map(string)
})
})
scale_down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, 0)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
job_retry = object({
enabled = bool
max_attempts = number
delay_in_seconds = number
delay_backoff = number
queue = optional(object({
arn = string
url = string
}), null)
})
}) | n/a | yes |
| [runner\_provider](#input\_runner\_provider) | Selected compute-provider integration for the scaling control plane.object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = string
additional_iam_policy_json = optional(string, null)
managed_policy = optional(object({
arn = string
}), null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = string
})
}) | n/a | yes |
| [storage\_provider](#input\_storage\_provider) | Resolved storage-provider configuration and capabilities for scale-up and scale-down. | object({
aws = object({
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = optional(string, null)
})
})
scale_up = optional(object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
}), {
environment_variables = {}
iam_policy_json = null
})
scale_down = optional(object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
}), {
environment_variables = {}
iam_policy_json = null
})
}) | n/a | yes |
diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf
index efe57570f6..772fbeb6c8 100644
--- a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf
+++ b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf
@@ -32,6 +32,7 @@ resource "aws_lambda_function" "scale_down" {
POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error
COMPUTE_PROVIDER_TYPE = var.runner_provider.type
RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes
+ ENABLE_MULTI_ORG_RUNNERS = var.config.github.multi_org_runners
}, {
PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name
PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name
diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-up.tf b/modules/orchestration-providers/webhook/scale-runners/scale-up.tf
index 3b66812f83..679e07e1e6 100644
--- a/modules/orchestration-providers/webhook/scale-runners/scale-up.tf
+++ b/modules/orchestration-providers/webhook/scale-runners/scale-up.tf
@@ -40,6 +40,7 @@ resource "aws_lambda_function" "scale_up" {
RUNNERS_MAXIMUM_COUNT = var.config.runner.maximum_count
POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-scale-up"
JOB_RETRY_CONFIG = jsonencode(local.job_retry_config)
+ ENABLE_MULTI_ORG_RUNNERS = var.config.github.multi_org_runners
}, {
PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name
PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name
diff --git a/modules/orchestration-providers/webhook/scale-runners/variables.tf b/modules/orchestration-providers/webhook/scale-runners/variables.tf
index 982fb4e844..f7e8f930e7 100644
--- a/modules/orchestration-providers/webhook/scale-runners/variables.tf
+++ b/modules/orchestration-providers/webhook/scale-runners/variables.tf
@@ -30,6 +30,7 @@ variable "config" {
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down.
- `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration.
- `github.organization_runners`: Registers organization runners when true.
+ - `github.multi_org_runners`: Opt-in multi-organization runners. Overrides repository scope, resolves installations per organization, and scopes runner-group caching and idle retention to each organization. Defaults to false.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS verification for GitHub Enterprise Server.
- `github.user_agent`: Optional User-Agent sent to GitHub.
@@ -106,6 +107,7 @@ variable "config" {
})
github = object({
organization_runners = bool
+ multi_org_runners = optional(bool, false)
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
diff --git a/modules/orchestration-providers/webhook/variables.tf b/modules/orchestration-providers/webhook/variables.tf
index e74602fd52..b9ec242872 100644
--- a/modules/orchestration-providers/webhook/variables.tf
+++ b/modules/orchestration-providers/webhook/variables.tf
@@ -25,6 +25,7 @@ variable "config" {
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `runner.ephemeral`.
- `runner.maximum_count`: Maximum number of runners managed for this runner configuration.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
+ - `github.multi_org_runners`: Opt-in multi-organization runners. Overrides repository scope, resolves installations per organization, and scopes runner-group caching and idle retention to each organization. Defaults to false.
- `queue.build.arn`: ARN of the runner configuration's build queue.
- `queue.build.url`: URL of the runner configuration's build queue.
- `queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. This is independent from the Parameter Store KMS key.
@@ -59,6 +60,7 @@ variable "config" {
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired number of runners for the schedule.
+ - `lambda.pool.config[].org`: Optional organization login for this schedule when multi-org mode is enabled. Omitted values use the default pool runner owner.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners.
- `lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags.
@@ -80,6 +82,7 @@ variable "config" {
})
github = object({
organization_runners = bool
+ multi_org_runners = optional(bool, false)
})
queue = object({
build = object({
@@ -131,6 +134,7 @@ variable "config" {
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
+ org = optional(string)
size = number
}))
include_busy_runners = bool
diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md
index 622632f3b5..e3d8491329 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
multi_org_runners = optional(bool, false)
})
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)
org = 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 |
| [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 |
| [storage\_provider](#input\_storage\_provider) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.object({
aws = object({
ssm = 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 |
diff --git a/modules/runner-config/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl
index 7f2a520cd2..1f6cb029f2 100644
--- a/modules/runner-config/tests/pool.tftest.hcl
+++ b/modules/runner-config/tests/pool.tftest.hcl
@@ -675,3 +675,40 @@ run "job_retry_uses_common_runner_configuration_identity" {
error_message = "Job retry must apply its configured Lambda reserved concurrency."
}
}
+
+
+run "multi_org_mode_reaches_all_lifecycle_functions" {
+ command = plan
+ variables {
+ orchestration_provider = {
+ webhook = {
+ github = { organization_runners = true, multi_org_runners = true }
+ queue = {
+ build = {
+ arn = "arn:aws:sqs:eu-west-1:123456789012:build-queue"
+ url = "https://sqs.eu-west-1.amazonaws.com/123456789012/build-queue"
+ }
+ }
+ lambda = {
+ artifact = { s3 = { key = "runners.zip" } }
+ pool = {
+ config = [
+ { schedule_expression = "cron(0 8 * * ? *)", size = 2, org = "org-a" },
+ { schedule_expression = "cron(0 8 * * ? *)", size = 5, org = "org-b" },
+ ]
+ }
+ }
+ job_retry = { enabled = true }
+ }
+ }
+ }
+ assert {
+ condition = (
+ module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true" &&
+ module.orchestration_webhook[0].scale_down.lambda.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true" &&
+ module.orchestration_webhook[0].pool.lambda.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true" &&
+ module.orchestration_webhook[0].job_retry.lambda.function.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true"
+ )
+ error_message = "Multi-org mode must reach every lifecycle Lambda through runner-config."
+ }
+}
diff --git a/modules/runner-config/variables.orchestration-provider.tf b/modules/runner-config/variables.orchestration-provider.tf
index 44fa3525fd..1550e161f8 100644
--- a/modules/runner-config/variables.orchestration-provider.tf
+++ b/modules/runner-config/variables.orchestration-provider.tf
@@ -10,6 +10,7 @@ variable "orchestration_provider" {
- `webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. The default is null, which follows `runner.ephemeral`.
- `webhook.runner.maximum_count`: Maximum number of runners managed for this runner configuration. The default is `3`.
- `webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
+ - `webhook.github.multi_org_runners`: Opt-in multi-organization runners. Overrides repository scope, resolves installations per organization, and scopes runner-group caching and idle retention to each organization. Defaults to false.
- `webhook.queue.build.arn`: ARN of the runner configuration's build queue.
- `webhook.queue.build.url`: URL of the runner configuration's build queue.
- `webhook.queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. The default is null and is independent from the Parameter Store KMS key.
@@ -44,6 +45,7 @@ variable "orchestration_provider" {
- `webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
+ - `webhook.lambda.pool.config[].org`: Optional organization login for this schedule when multi-org mode is enabled. Omitted values use the default pool runner owner.
- `webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `webhook.lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags. The default is `{}`.
@@ -66,6 +68,7 @@ variable "orchestration_provider" {
}), {})
github = object({
organization_runners = bool
+ multi_org_runners = optional(bool, false)
})
queue = object({
build = object({
@@ -117,6 +120,7 @@ variable "orchestration_provider" {
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
+ org = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
diff --git a/modules/runners/README.md b/modules/runners/README.md
index 78d1932e09..e490c9d754 100644
--- a/modules/runners/README.md
+++ b/modules/runners/README.md
@@ -153,6 +153,7 @@ yarn run dist
| [enable\_jit\_config](#input\_enable\_jit\_config) | Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI. | `bool` | `null` | no |
| [enable\_job\_queued\_check](#input\_enable\_job\_queued\_check) | Only scale if the job event received by the scale up lambda is is in the state queued. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior. | `bool` | `null` | no |
| [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no |
+| [enable\_multi\_org\_runners](#input\_enable\_multi\_org\_runners) | Enable organization-scoped runners across multiple GitHub organizations. Resolves app installations per organization, scopes runner-group caches and idle retention by organization, and enables pool\_config.org. | `bool` | `false` | no |
| [enable\_on\_demand\_failover\_for\_errors](#input\_enable\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no |
| [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | n/a | yes |
| [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no |
@@ -199,7 +200,7 @@ yarn run dist
| [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. | `map(string)` | {
"name_runner": "",
"name_sg": ""
} | 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 |
| [placement](#input\_placement) | The placement options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#placement for details. | 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` | no |
-| [pool\_config](#input\_pool\_config) | The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone ` to override the schedule time zone (defaults to UTC). | list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})) | `[]` | no |
+| [pool\_config](#input\_pool\_config) | The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone ` to override the schedule time zone (defaults to UTC). | list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
org = optional(string)
size = number
})) | `[]` | no |
| [pool\_include\_busy\_runners](#input\_pool\_include\_busy\_runners) | Include busy runners in the pool calculation. By default busy runners are not included in the pool. | `bool` | `false` | no |
| [pool\_lambda\_memory\_size](#input\_pool\_lambda\_memory\_size) | Lambda Memory size limit in MB for pool lambda | `number` | `512` | 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 |
diff --git a/modules/runners/job-retry.tf b/modules/runners/job-retry.tf
index 00ed54d8e1..ba0887f22a 100644
--- a/modules/runners/job-retry.tf
+++ b/modules/runners/job-retry.tf
@@ -26,6 +26,7 @@ locals {
tracing_config = var.tracing_config
github_app_parameters = var.github_app_parameters
enable_organization_runners = var.enable_organization_runners
+ enable_multi_org_runners = var.enable_multi_org_runners
sqs_build_queue = var.sqs_build_queue
ghes_url = var.ghes_url
lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size
diff --git a/modules/runners/job-retry/README.md b/modules/runners/job-retry/README.md
index 88ec3bdde0..0a5556e132 100644
--- a/modules/runners/job-retry/README.md
+++ b/modules/runners/job-retry/README.md
@@ -42,7 +42,7 @@ The module is an inner module and used by the runner module when the opt-in feat
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
-| [config](#input\_config) | Configuration for the spot termination watcher lambda function.object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_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), [])
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
}) | n/a | yes |
+| [config](#input\_config) | Configuration for the spot termination watcher lambda function.object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
enable_multi_org_runners = optional(bool, false)
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_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), [])
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
}) | n/a | yes |
## Outputs
diff --git a/modules/runners/job-retry/main.tf b/modules/runners/job-retry/main.tf
index 287d63d571..faaf1e16e6 100644
--- a/modules/runners/job-retry/main.tf
+++ b/modules/runners/job-retry/main.tf
@@ -5,6 +5,7 @@ locals {
environment_variables = {
ENABLE_ORGANIZATION_RUNNERS = var.config.enable_organization_runners
ENABLE_METRIC_JOB_RETRY = var.config.metrics.enable && var.config.metrics.metric.enable_job_retry
+ ENABLE_MULTI_ORG_RUNNERS = var.config.enable_multi_org_runners
ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.metrics.enable && var.config.metrics.metric.enable_github_app_rate_limit
GHES_URL = var.config.ghes_url
USER_AGENT = var.config.user_agent
diff --git a/modules/runners/job-retry/variables.tf b/modules/runners/job-retry/variables.tf
index 1a2fff1dc1..a07c62a689 100644
--- a/modules/runners/job-retry/variables.tf
+++ b/modules/runners/job-retry/variables.tf
@@ -40,6 +40,7 @@ variable "config" {
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
+ enable_multi_org_runners = optional(bool, false)
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
diff --git a/modules/runners/pool.tf b/modules/runners/pool.tf
index 1f4b99dfd8..c2e70a051a 100644
--- a/modules/runners/pool.tf
+++ b/modules/runners/pool.tf
@@ -4,7 +4,8 @@ module "pool" {
source = "./pool"
config = {
- prefix = var.prefix
+ enable_multi_org_runners = var.enable_multi_org_runners
+ prefix = var.prefix
ghes = {
ssl_verify = var.ghes_ssl_verify
url = var.ghes_url
diff --git a/modules/runners/pool/README.md b/modules/runners/pool/README.md
index 1b00e0d052..d130e9af9c 100644
--- a/modules/runners/pool/README.md
+++ b/modules/runners/pool/README.md
@@ -49,7 +49,7 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no |
-| [config](#input\_config) | Lookup details in parent module. | object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_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), [])
})
subnet_ids = list(string)
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
enable_on_demand_failover_for_errors = list(string)
scale_errors = list(string)
boot_time_in_minutes = number
labels = list(string)
launch_template = object({
name = string
})
group_name = string
name_prefix = string
pool_owner = string
role = object({
arn = string
})
use_dedicated_host = bool
})
runners_maximum_count = number
instance_types = list(string)
instance_type_priorities = optional(map(number))
instance_target_capacity_type = string
instance_allocation_strategy = string
instance_max_spot_price = string
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
ami_kms_key_arn = string
ami_id_ssm_parameter_arn = string
role_path = string
ssm_token_path = string
ssm_ttl_seconds = optional(object({
tokens = optional(number, null)
}), {})
ssm_config_path = string
ami_id_ssm_parameter_name = string
ami_id_ssm_parameter_read_policy_arn = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
}) | n/a | yes |
+| [config](#input\_config) | Lookup details in parent module. | object({
enable_multi_org_runners = optional(bool, false)
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_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), [])
})
subnet_ids = list(string)
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
enable_on_demand_failover_for_errors = list(string)
scale_errors = list(string)
boot_time_in_minutes = number
labels = list(string)
launch_template = object({
name = string
})
group_name = string
name_prefix = string
pool_owner = string
role = object({
arn = string
})
use_dedicated_host = bool
})
runners_maximum_count = number
instance_types = list(string)
instance_type_priorities = optional(map(number))
instance_target_capacity_type = string
instance_allocation_strategy = string
instance_max_spot_price = string
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
org = optional(string)
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_arn = string
ami_kms_key_arn = string
ami_id_ssm_parameter_arn = string
role_path = string
ssm_token_path = string
ssm_ttl_seconds = optional(object({
tokens = optional(number, null)
}), {})
ssm_config_path = string
ami_id_ssm_parameter_name = string
ami_id_ssm_parameter_read_policy_arn = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
}) | n/a | yes |
| [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. | object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}) | `{}` | no |
## Outputs
diff --git a/modules/runners/pool/main.tf b/modules/runners/pool/main.tf
index 166836b08e..c415411a90 100644
--- a/modules/runners/pool/main.tf
+++ b/modules/runners/pool/main.tf
@@ -50,6 +50,7 @@ resource "aws_lambda_function" "pool" {
RUNNER_LABELS = lower(join(",", var.config.runner.labels))
RUNNER_GROUP_NAME = var.config.runner.group_name
RUNNER_NAME_PREFIX = var.config.runner.name_prefix
+ ENABLE_MULTI_ORG_RUNNERS = var.config.enable_multi_org_runners
RUNNER_OWNER = var.config.runner.pool_owner
RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count
SSM_TOKEN_PATH = var.config.ssm_token_path
@@ -237,9 +238,9 @@ resource "aws_scheduler_schedule" "pool" {
target {
arn = aws_lambda_function.pool.arn
role_arn = aws_iam_role.scheduler.arn
- input = jsonencode({
+ input = jsonencode(merge({
poolSize = each.value.size
type = "ec2"
- })
+ }, var.config.enable_multi_org_runners ? { org = each.value.org } : {}))
}
}
diff --git a/modules/runners/pool/tests/login.tftest.hcl b/modules/runners/pool/tests/login.tftest.hcl
new file mode 100644
index 0000000000..46e64a8e38
--- /dev/null
+++ b/modules/runners/pool/tests/login.tftest.hcl
@@ -0,0 +1,189 @@
+mock_provider "aws" {
+ mock_data "aws_iam_policy_document" {
+ defaults = {
+ json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
+ }
+ }
+}
+
+variables {
+ config = {
+ enable_multi_org_runners = true
+ prefix = "pool-login-test"
+ lambda = {
+ log_level = "info"
+ logging_retention_in_days = 14
+ logging_kms_key_id = null
+ log_class = "STANDARD"
+ reserved_concurrent_executions = 1
+ s3_bucket = "lambda-artifacts"
+ s3_key = "runners.zip"
+ s3_object_version = null
+ security_group_ids = []
+ runtime = "nodejs24.x"
+ architecture = "arm64"
+ memory_size = 256
+ timeout = 60
+ zip = null
+ subnet_ids = []
+ parameter_store_tags = "{}"
+ }
+ tags = {}
+ ghes = { url = null, ssl_verify = "true" }
+ github_app_parameters = {
+ id = { name = "/test/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/test/app-id" }
+ key_base64 = { name = "/test/app-key", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/test/app-key" }
+ }
+ subnet_ids = ["subnet-test"]
+ runner = {
+ disable_runner_autoupdate = false
+ ephemeral = true
+ enable_jit_config = true
+ enable_on_demand_failover_for_errors = []
+ scale_errors = []
+ boot_time_in_minutes = 5
+ labels = ["self-hosted"]
+ launch_template = { name = "test" }
+ group_name = "Default"
+ name_prefix = "test"
+ pool_owner = "default-org"
+ role = { arn = "arn:aws:iam::123456789012:role/runner" }
+ use_dedicated_host = false
+ }
+ runners_maximum_count = 10
+ instance_types = ["m5.large"]
+ instance_target_capacity_type = "spot"
+ instance_allocation_strategy = "lowest-price"
+ instance_max_spot_price = null
+ pool = [{ schedule_expression = "cron(0 8 * * ? *)", schedule_expression_timezone = "UTC", size = 1 }]
+ include_busy_runners = false
+ role_permissions_boundary = null
+ kms_key_arn = ""
+ ami_kms_key_arn = ""
+ ami_id_ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/test/ami"
+ role_path = "/"
+ ssm_token_path = "/test/tokens"
+ ssm_config_path = "/test/config"
+ ami_id_ssm_parameter_name = null
+ ami_id_ssm_parameter_read_policy_arn = null
+ arn_ssm_parameters_path_config = "arn:aws:ssm:eu-west-1:123456789012:parameter/test/config"
+ lambda_tags = {}
+ user_agent = "terraform-aws-github-runner"
+ }
+}
+
+run "rejects_trailing_hyphen_override" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [merge(var.config.pool[0], { org = "org-" })]
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_trailing_hyphen_default" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ runner = merge(var.config.runner, { pool_owner = "org-" })
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_repeated_hyphen_override" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [merge(var.config.pool[0], { org = "org--name" })]
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_repeated_hyphen_default" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ runner = merge(var.config.runner, { pool_owner = "org--name" })
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_leading_hyphen_override" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [merge(var.config.pool[0], { org = "-org" })]
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_leading_hyphen_default" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ runner = merge(var.config.runner, { pool_owner = "-org" })
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_too_long_override" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [merge(var.config.pool[0], { org = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" })]
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "rejects_too_long_default" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ runner = merge(var.config.runner, { pool_owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" })
+ })
+ }
+ expect_failures = [var.config]
+}
+
+run "accepts_valid_logins_and_length_boundary" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = true
+ pool = [for org in ["a", "Org-1", "org-a-b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1"] : merge(var.config.pool[0], { org = org })]
+ })
+ }
+ assert {
+ condition = length(aws_scheduler_schedule.pool) == 5
+ error_message = "Valid logins including the 39-character boundary must be accepted."
+ }
+}
+
+run "preserves_disabled_mode_login_handling" {
+ command = plan
+ variables {
+ config = merge(var.config, {
+ enable_multi_org_runners = false
+ runner = merge(var.config.runner, { pool_owner = "org--name" })
+ })
+ }
+ assert {
+ condition = aws_lambda_function.pool.environment[0].variables["RUNNER_OWNER"] == "org--name"
+ error_message = "Stricter validation must remain gated by multi-org mode."
+ }
+}
diff --git a/modules/runners/pool/variables.tf b/modules/runners/pool/variables.tf
index 20e5f80c60..7afacb18a4 100644
--- a/modules/runners/pool/variables.tf
+++ b/modules/runners/pool/variables.tf
@@ -1,6 +1,7 @@
variable "config" {
description = "Lookup details in parent module."
type = object({
+ enable_multi_org_runners = optional(bool, false)
lambda = object({
log_level = string
logging_retention_in_days = number
@@ -63,6 +64,7 @@ variable "config" {
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
+ org = optional(string)
size = number
}))
include_busy_runners = bool
@@ -82,6 +84,17 @@ variable "config" {
lambda_tags = map(string)
user_agent = string
})
+
+ validation {
+ condition = !var.config.enable_multi_org_runners || alltrue([
+ for pool in var.config.pool : try(
+ length(pool.org == null ? var.config.runner.pool_owner : pool.org) <= 39 &&
+ can(regex("^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$", pool.org == null ? var.config.runner.pool_owner : pool.org)),
+ false
+ )
+ ])
+ error_message = "Multi-org pools require an organization login in each schedule's org or the default pool owner: 1-39 alphanumeric characters or single hyphens, with no leading or trailing hyphen."
+ }
}
variable "aws_partition" {
diff --git a/modules/runners/scale-down.tf b/modules/runners/scale-down.tf
index ff7c91dff8..9b012be83f 100644
--- a/modules/runners/scale-down.tf
+++ b/modules/runners/scale-down.tf
@@ -28,6 +28,7 @@ resource "aws_lambda_function" "scale_down" {
environment {
variables = {
ENVIRONMENT = var.prefix
+ ENABLE_MULTI_ORG_RUNNERS = var.enable_multi_org_runners
ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.metrics.enable && var.metrics.metric.enable_github_app_rate_limit
GHES_URL = var.ghes_url
USER_AGENT = var.user_agent
diff --git a/modules/runners/scale-up.tf b/modules/runners/scale-up.tf
index dfe8c0a561..0754277eae 100644
--- a/modules/runners/scale-up.tf
+++ b/modules/runners/scale-up.tf
@@ -35,6 +35,7 @@ resource "aws_lambda_function" "scale_up" {
ENABLE_EPHEMERAL_RUNNERS = var.enable_ephemeral_runners
ENABLE_JIT_CONFIG = var.enable_jit_config
ENABLE_JOB_QUEUED_CHECK = local.enable_job_queued_check
+ ENABLE_MULTI_ORG_RUNNERS = var.enable_multi_org_runners
ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.metrics.enable && var.metrics.metric.enable_github_app_rate_limit
ENABLE_ORGANIZATION_RUNNERS = var.enable_organization_runners
ENVIRONMENT = var.prefix
diff --git a/modules/runners/tests/pool.tftest.hcl b/modules/runners/tests/pool.tftest.hcl
index 874e19f2d6..8767d6af85 100644
--- a/modules/runners/tests/pool.tftest.hcl
+++ b/modules/runners/tests/pool.tftest.hcl
@@ -105,3 +105,22 @@ run "reject_non_positive_token_ttl" {
}
expect_failures = [var.ssm_ttl_seconds]
}
+
+run "multi_org_flag_reaches_control_plane" {
+ command = plan
+ variables {
+ enable_multi_org_runners = true
+ pool_config = [
+ { schedule_expression = "cron(0 8 * * ? *)", size = 2, org = "org-a" },
+ { schedule_expression = "cron(0 8 * * ? *)", size = 5, org = "org-b" },
+ ]
+ }
+ assert {
+ condition = (
+ aws_lambda_function.scale_up.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true" &&
+ aws_lambda_function.scale_down.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true" &&
+ module.pool[0].lambda.environment[0].variables["ENABLE_MULTI_ORG_RUNNERS"] == "true"
+ )
+ error_message = "Multi-org mode must reach the scale-up, scale-down and pool Lambdas."
+ }
+}
diff --git a/modules/runners/variables.tf b/modules/runners/variables.tf
index b99e5c991e..afb5a86600 100644
--- a/modules/runners/variables.tf
+++ b/modules/runners/variables.tf
@@ -220,6 +220,12 @@ variable "sqs_build_queue" {
})
}
+variable "enable_multi_org_runners" {
+ description = "Enable organization-scoped runners across multiple GitHub organizations. Resolves app installations per organization, scopes runner-group caches and idle retention by organization, and enables pool_config.org."
+ type = bool
+ default = false
+}
+
variable "enable_organization_runners" {
description = "Register runners to organization, instead of repo level"
type = bool
@@ -629,6 +635,7 @@ variable "pool_config" {
type = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
+ org = optional(string)
size = number
}))
default = []
diff --git a/variables.tf b/variables.tf
index 6bad7a21b0..0ef4fa010c 100644
--- a/variables.tf
+++ b/variables.tf
@@ -25,6 +25,12 @@ variable "prefix" {
default = "github-actions"
}
+variable "enable_multi_org_runners" {
+ description = "Enable organization-scoped runners across multiple GitHub organizations. Resolves app installations per organization, scopes runner-group caches and idle retention by organization, and enables pool_config.org."
+ type = bool
+ default = false
+}
+
variable "enable_organization_runners" {
description = "Register runners to organization, instead of repo level"
type = bool
@@ -881,10 +887,11 @@ variable "pool_lambda_reserved_concurrent_executions" {
}
variable "pool_config" {
- description = "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for weekdays to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
+ description = "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for weekdays to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC). With `enable_multi_org_runners`, set `org` per schedule; omitted values use `pool_runner_owner`."
type = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
+ org = optional(string)
size = number
}))
default = []