From 6be5a10e4c9efee57e4523750412c739b378bc18 Mon Sep 17 00:00:00 2001 From: Guilherme Caulada Date: Mon, 14 Sep 2026 10:14:25 -0300 Subject: [PATCH 1/4] feat: add opt-in multi-organization runner support --- README.md | 4 +- docs/multi-org.md | 45 +++++++ .../control-plane/src/github/multi-org.ts | 6 + .../control-plane/src/github/octokit.test.ts | 27 +++++ .../control-plane/src/github/octokit.ts | 3 +- .../control-plane/src/pool/pool.test.ts | 52 +++++++++ .../functions/control-plane/src/pool/pool.ts | 11 +- .../github-runner.multi-org.test.ts | 110 ++++++++++++++++++ .../src/scale-runners/github-runner.ts | 10 +- .../src/scale-runners/job-retry.ts | 3 +- .../src/scale-runners/scale-down.test.ts | 53 +++++++++ .../src/scale-runners/scale-down.ts | 6 +- .../scale-runners/scale-up-contract.test.ts | 33 +++++- .../src/scale-runners/scale-up.ts | 3 +- main.tf | 1 + mkdocs.yaml | 1 + modules/multi-runner/README.md | 4 +- .../config.experimental.translation.tf | 1 + modules/multi-runner/runners.tf | 1 + .../tests/config-effective.tftest.hcl | 24 +++- ...les.experimental.orchestration-provider.tf | 1 + modules/multi-runner/variables.tf | 4 + .../orchestration-providers/webhook/README.md | 2 +- .../webhook/job-retry/README.md | 2 +- .../webhook/job-retry/job-retry.tf | 1 + .../webhook/job-retry/variables.tf | 2 + .../orchestration-providers/webhook/pool.tf | 3 +- .../webhook/pool/README.md | 2 +- .../webhook/pool/pool.tf | 5 +- .../webhook/pool/tests/provider.tftest.hcl | 57 +++++++++ .../webhook/pool/variables.tf | 9 ++ .../webhook/scale-runners/README.md | 2 +- .../webhook/scale-runners/scale-down.tf | 1 + .../webhook/scale-runners/scale-up.tf | 1 + .../webhook/scale-runners/variables.tf | 2 + .../webhook/variables.tf | 4 + modules/runner-config/README.md | 2 +- modules/runner-config/tests/pool.tftest.hcl | 37 ++++++ .../variables.orchestration-provider.tf | 4 + modules/runners/README.md | 3 +- modules/runners/job-retry.tf | 1 + modules/runners/job-retry/README.md | 2 +- modules/runners/job-retry/main.tf | 1 + modules/runners/job-retry/variables.tf | 1 + modules/runners/pool.tf | 3 +- modules/runners/pool/README.md | 2 +- modules/runners/pool/main.tf | 5 +- modules/runners/pool/variables.tf | 9 ++ modules/runners/scale-down.tf | 1 + modules/runners/scale-up.tf | 1 + modules/runners/tests/pool.tftest.hcl | 19 +++ modules/runners/variables.tf | 7 ++ variables.tf | 9 +- 53 files changed, 571 insertions(+), 32 deletions(-) create mode 100644 docs/multi-org.md create mode 100644 lambdas/functions/control-plane/src/github/multi-org.ts create mode 100644 lambdas/functions/control-plane/src/scale-runners/github-runner.multi-org.test.ts 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..d0246a788f --- /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 as returned by GitHub, rather than its display name. 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. Other compute providers use the equivalent owner/type fields in their provider contract. +- Scale-down applies the existing idle configuration independently to each organization. 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..e8177e3955 --- /dev/null +++ b/lambdas/functions/control-plane/src/github/multi-org.ts @@ -0,0 +1,6 @@ +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 }); +} 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< + ReturnType + >); + vi.mocked(getStoredInstallationId).mockResolvedValue(999); + mockOctokit.apps.getOrgInstallation.mockResolvedValue({ data: { id: 20 } }); + await getOctokit('', true, { + eventType: 'workflow_job', + id: 1, + repositoryOwner: 'org-b', + repositoryName: 'repo', + repoOwnerType: 'Organization', + installationId: 10, + }); + expect(getStoredInstallationId).not.toHaveBeenCalled(); + expect(createGithubInstallationAuth).toHaveBeenCalledWith(appIndex === 0 ? 10 : 20, '', appIndex); + if (appIndex === 1) expect(mockOctokit.apps.getOrgInstallation).toHaveBeenCalledWith({ org: 'org-b' }); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/lambdas/functions/control-plane/src/github/octokit.ts b/lambdas/functions/control-plane/src/github/octokit.ts index 46b292686c..cb9641d5bf 100644 --- a/lambdas/functions/control-plane/src/github/octokit.ts +++ b/lambdas/functions/control-plane/src/github/octokit.ts @@ -7,6 +7,7 @@ import { createOctokitClient, getStoredInstallationId, } from './auth'; +import { multiOrgEnabled } from './multi-org'; const logger = createChildLogger('octokit'); @@ -45,7 +46,7 @@ async function resolveInstallationId( appIndex?: number, ): Promise { // Use pre-stored installation ID when available (avoids an API call) - if (appIndex !== undefined) { + if (!multiOrgEnabled() && appIndex !== undefined) { const storedId = await getStoredInstallationId(appIndex); if (storedId !== undefined) return storedId; } diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index 5253fd5147..9d17fa4be0 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -363,3 +363,55 @@ describe('pool adjustment', () => { }); }); }); + +describe('multi-org pools', () => { + it('isolates organization installation, capacity lookup and runner registration for each schedule', async () => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + vi.mocked(ghAuth.getStoredInstallationId).mockResolvedValueOnce(999); + for (const org of ['org-a', 'org-b']) { + await adjust({ poolSize: 3, org }); + expect(githubClient.apps.getOrgInstallation).toHaveBeenLastCalledWith({ org }); + expect(githubClient.paginate).toHaveBeenLastCalledWith(githubClient.actions.listSelfHostedRunnersForOrg, { + org, + per_page: 100, + }); + expect(poolProvider.listRunners).toHaveBeenLastCalledWith({ + environment: process.env.ENVIRONMENT, + runnerOwner: org, + runnerType: 'Org', + }); + expect(poolProvider.createRunners).toHaveBeenLastCalledWith( + expect.objectContaining({ + numberOfRunners: 1, + githubRunnerConfig: expect.objectContaining({ runnerOwner: org, runnerType: 'Org' }), + }), + ); + } + expect(ghAuth.getStoredInstallationId).not.toHaveBeenCalled(); + vi.mocked(ghAuth.getStoredInstallationId).mockReset().mockResolvedValue(undefined); + }); + + it('uses the default owner for a schedule without an org in multi-org mode', async () => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + await adjust({ poolSize: 3 }); + expect(githubClient.apps.getOrgInstallation).toHaveBeenCalledWith({ org: ORG }); + }); + + it.each([undefined, 'owner/repo', ''])('rejects a missing or invalid owner %s before GitHub calls', async (org) => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + delete process.env.RUNNER_OWNER; + await expect(adjust({ poolSize: 3, org })).rejects.toThrow('Multi-org pools require an organization'); + expect(mockedAppAuth).not.toHaveBeenCalled(); + }); + + it('ignores event.org when multi-org is disabled', async () => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'false'; + await adjust({ poolSize: 3, org: 'org-b' }); + expect(githubClient.apps.getOrgInstallation).toHaveBeenCalledWith({ org: ORG }); + expect(poolProvider.createRunners).toHaveBeenCalledWith( + expect.objectContaining({ + githubRunnerConfig: expect.objectContaining({ runnerOwner: ORG }), + }), + ); + }); +}); diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index 9f91c6cbcd..0a1aa6794f 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -4,6 +4,7 @@ import { resolveComputeProviderType } from '@aws-github-runner/compute-providers import { createStorageProviders, type StorageProviders } from '@aws-github-runner/storage-providers'; import yn from 'yn'; +import { multiOrgEnabled } from '../github/multi-org'; import { createGithubAppAuth, createGithubInstallationAuth, @@ -18,6 +19,7 @@ const logger = createChildLogger('pool'); export interface PoolEvent { poolSize: number; + org?: string; type?: string; } @@ -36,7 +38,10 @@ export async function adjust(event: PoolEvent): Promise { const ephemeral = yn(process.env.ENABLE_EPHEMERAL_RUNNERS, { default: false }); const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeral }); const disableAutoUpdate = yn(process.env.DISABLE_RUNNER_AUTOUPDATE, { default: false }); - const runnerOwner = process.env.RUNNER_OWNER; + const runnerOwner = multiOrgEnabled() ? (event.org ?? process.env.RUNNER_OWNER) : process.env.RUNNER_OWNER; + if (multiOrgEnabled() && (!runnerOwner || !/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(runnerOwner))) { + throw new Error('Multi-org pools require an organization in event.org or RUNNER_OWNER'); + } // -1 disables the maximum check, matching the scale-up lambda's semantics. Defaults to unlimited // when unset so the pool keeps its previous behavior on stacks that do not provide the variable. const maximumRunners = parseInt(process.env.RUNNERS_MAXIMUM_COUNT || '-1'); @@ -117,7 +122,9 @@ async function getInstallationId( storage?: StorageProviders, ): Promise { // Use the pre-configured installation ID when available (avoids an API call). - const storedId = await getStoredInstallationId(appIndex, storage?.githubAppCredentials); + const storedId = multiOrgEnabled() + ? undefined + : await getStoredInstallationId(appIndex, storage?.githubAppCredentials); if (storedId !== undefined) return storedId; const githubClient = await createOctokitClient(appToken, ghesApiUrl, appIndex); diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.multi-org.test.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.multi-org.test.ts new file mode 100644 index 0000000000..923f3ac722 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.multi-org.test.ts @@ -0,0 +1,110 @@ +import type { Octokit } from '@octokit/rest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { RunnerGroupCacheStore } from '@aws-github-runner/storage-providers'; + +import { getStoredInstallationId } from '../github/auth'; +import { createStartRunnerConfig, getInstallationId, getRunnerGroupId } from './github-runner'; +import type { ActionRequestMessage, CreateGitHubRunnerConfig } from './types'; + +vi.mock('../github/auth', () => ({ getStoredInstallationId: vi.fn().mockResolvedValue(999) })); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +const payload: ActionRequestMessage = { + id: 1, + eventType: 'workflow_job', + repositoryOwner: 'org-a', + repositoryName: 'repo', + repoOwnerType: 'Organization', + installationId: 10, +}; + +const config: CreateGitHubRunnerConfig = { + ephemeral: true, + enableJitConfig: true, + runnerOwner: 'org-a', + runnerType: 'Org', + runnerGroup: 'Default', + runnerLabels: 'self-hosted,linux', + runnerNamePrefix: '', + disableAutoUpdate: false, +}; + +describe('multi-org registration', () => { + it.each([undefined, 'false'])('preserves stored installation IDs with flag %s', async (flag) => { + vi.stubEnv('ENABLE_MULTI_ORG_RUNNERS', flag); + expect(await getInstallationId({} as Octokit, true, payload, 0)).toBe(999); + }); + + it('uses each webhook installation for the primary app, ignoring the global installation', async () => { + vi.stubEnv('ENABLE_MULTI_ORG_RUNNERS', 'true'); + expect(await getInstallationId({} as Octokit, true, payload, 0)).toBe(10); + expect( + await getInstallationId({} as Octokit, true, { ...payload, installationId: 20, repositoryOwner: 'org-b' }, 0), + ).toBe(20); + expect(getStoredInstallationId).not.toHaveBeenCalled(); + }); + + it.each([0, 1])('resolves missing or additional-app installations for the target org (app %s)', async (appIndex) => { + vi.stubEnv('ENABLE_MULTI_ORG_RUNNERS', 'true'); + const getOrgInstallation = vi.fn().mockResolvedValue({ data: { id: 30 } }); + const client = { apps: { getOrgInstallation } } as unknown as Octokit; + expect( + await getInstallationId(client, true, { ...payload, installationId: appIndex === 0 ? 0 : 10 }, appIndex), + ).toBe(30); + expect(getOrgInstallation).toHaveBeenCalledWith({ org: 'org-a' }); + expect(getStoredInstallationId).not.toHaveBeenCalled(); + }); + + it('generates JIT configs with separate group IDs for identically named groups in two orgs', async () => { + vi.stubEnv('ENABLE_MULTI_ORG_RUNNERS', 'true'); + // An existing unscoped entry must not be reused in multi-org mode. + const groups = new Map([['Default', 999]]); + const runnerGroupCacheStore: RunnerGroupCacheStore = { + get: vi.fn(async (key) => groups.get(key)), + create: vi.fn(async ({ runnerGroupName, runnerGroupId }) => { + groups.set(runnerGroupName, runnerGroupId); + }), + }; + const paginate = vi + .fn() + .mockImplementation(async (_route, { org }) => [{ name: 'Default', id: org === 'org-a' ? 11 : 22 }]); + const generateRunnerJitconfigForOrg = vi + .fn() + .mockResolvedValue({ data: { runner: { id: 1 }, encoded_jit_config: 'jit' }, headers: {} }); + const client = { paginate, actions: { generateRunnerJitconfigForOrg } } as unknown as Octokit; + const runnerConfigStore = { create: vi.fn().mockResolvedValue(undefined) }; + for (const org of ['org-a', 'org-b', 'org-a']) { + expect( + await createStartRunnerConfig({ ...config, runnerOwner: org }, [`runner-${org}`], client, { + runnerConfigStore, + runnerGroupCacheStore, + }), + ).toEqual([]); + } + expect(paginate).toHaveBeenCalledTimes(2); + expect(generateRunnerJitconfigForOrg).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ org: 'org-a', runner_group_id: 11 }), + ); + expect(generateRunnerJitconfigForOrg).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ org: 'org-b', runner_group_id: 22 }), + ); + expect(generateRunnerJitconfigForOrg).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ org: 'org-a', runner_group_id: 11 }), + ); + expect(runnerGroupCacheStore.get).not.toHaveBeenCalledWith('Default'); + }); + + it('preserves the existing group cache key when disabled', async () => { + vi.stubEnv('ENABLE_MULTI_ORG_RUNNERS', 'false'); + const cache = { get: vi.fn().mockResolvedValue(7), create: vi.fn() }; + expect(await getRunnerGroupId(config, {} as Octokit, cache)).toBe(7); + expect(cache.get).toHaveBeenCalledWith('Default'); + }); +}); diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index f968fa3008..9980cfb5f1 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -9,6 +9,7 @@ import { import { Octokit } from '@octokit/rest'; import type { ResponseHeaders } from '@octokit/types'; +import { multiOrgEnabled } from '../github/multi-org'; import { getStoredInstallationId } from '../github/auth'; import { metricGitHubAppRateLimit } from '../github/rate-limit'; import { ActionRequestMessage, CreateGitHubRunnerConfig, EphemeralRunnerConfig, RunnerGroup } from './types'; @@ -111,7 +112,7 @@ export async function getInstallationId( credentialsStore?: GitHubAppCredentialsStore, ): Promise { // Use the pre-configured installation ID when available (avoids an API call). - if (appIndex !== undefined) { + if (!multiOrgEnabled() && appIndex !== undefined) { const storedId = await getStoredInstallationId(appIndex, credentialsStore); if (storedId !== undefined) return storedId; } @@ -178,12 +179,15 @@ export async function getRunnerGroupId( let runnerGroupId: number | undefined = 1; if (githubRunnerConfig.runnerType === 'Org' && githubRunnerConfig.runnerGroup !== undefined) { const cacheStore = runnerGroupCacheStore ?? createStorageProviders().runnerGroupCache; - const runnerGroup = await cacheStore.get(githubRunnerConfig.runnerGroup); + const cacheKey = multiOrgEnabled() + ? `${githubRunnerConfig.runnerOwner.toLowerCase()}/${githubRunnerConfig.runnerGroup}` + : githubRunnerConfig.runnerGroup; + const runnerGroup = await cacheStore.get(cacheKey); if (runnerGroup === undefined) { // get runner group id from GitHub runnerGroupId = await getRunnerGroupByName(ghClient, githubRunnerConfig); await cacheStore.create({ - runnerGroupName: githubRunnerConfig.runnerGroup, + runnerGroupName: cacheKey, runnerGroupId, }); } else { diff --git a/lambdas/functions/control-plane/src/scale-runners/job-retry.ts b/lambdas/functions/control-plane/src/scale-runners/job-retry.ts index 8f7d6e2289..6525820fdc 100644 --- a/lambdas/functions/control-plane/src/scale-runners/job-retry.ts +++ b/lambdas/functions/control-plane/src/scale-runners/job-retry.ts @@ -5,6 +5,7 @@ import type { ActionRequestMessage, ActionRequestMessageRetry } from './types'; import { getOctokit } from '../github/octokit'; import { MetricUnit } from '@aws-lambda-powertools/metrics'; import yn from 'yn'; +import { multiOrgEnabled } from '../github/multi-org'; interface JobRetryConfig { enable: boolean; @@ -38,7 +39,7 @@ export async function publishRetryMessage(payload: ActionRequestMessage): Promis } export async function checkAndRetryJob(payload: ActionRequestMessageRetry): Promise { - const enableOrgLevel = yn(process.env.ENABLE_ORGANIZATION_RUNNERS, { default: true }); + const enableOrgLevel = multiOrgEnabled() || yn(process.env.ENABLE_ORGANIZATION_RUNNERS, { default: true }); const runnerType = enableOrgLevel ? 'Org' : 'Repo'; const runnerOwner = enableOrgLevel ? payload.repositoryOwner : `${payload.repositoryOwner}/${payload.repositoryName}`; const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX ?? ''; diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts index 3583247f8d..48102b1011 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts @@ -262,6 +262,59 @@ describe('Scale down runners', () => { mockCreateClient.mockResolvedValue(mockOctokit as unknown as Octokit); }); + describe('multi-org lifecycle', () => { + it.each([true, false])('applies idle retention per organization only when enabled=%s', async (enabled) => { + process.env.ENABLE_MULTI_ORG_RUNNERS = String(enabled); + process.env.SCALE_DOWN_CONFIG = JSON.stringify([{ idleCount: 1, cron: '* * * * * *', timeZone: 'UTC' }]); + const runners = ['org-a', 'org-b'].map((org) => createRunnerTestData(org, 'Org', 60, true, false, false, org)); + mockProviderRunners(runners); + mockOctokit.paginate.mockImplementation(async (_route, { org }) => + runners.filter((runner) => runner.owner === org).map((runner) => ({ id: runner.id, name: runner.id })), + ); + await scaleDown(); + expect(mockTerminateRunners).toHaveBeenCalledTimes(enabled ? 0 : 1); + expect(mockOctokit.apps.getOrgInstallation).toHaveBeenCalledWith({ org: 'org-a' }); + expect(mockOctokit.apps.getOrgInstallation).toHaveBeenCalledWith({ org: 'org-b' }); + }); + + it('checks tagged orphans against their owning organization even when runner IDs overlap', async () => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + vi.mocked(ghAuth.getStoredInstallationId).mockResolvedValueOnce(999); + const runners = ['org-a', 'org-b'].map((org) => createRunnerTestData(org, 'Org', 60, true, true, false, org, 42)); + mockProviderRunners(runners); + mockOctokit.actions.getSelfHostedRunnerForOrg.mockImplementation(async ({ org }) => { + if (org === 'org-a') + throw new RequestError('Not Found', 404, { request: { method: 'GET', url: '', headers: {} } }); + return { data: { busy: true, status: 'online' } }; + }); + await scaleDown(); + expect(ghAuth.getStoredInstallationId).not.toHaveBeenCalled(); + expect(mockOctokit.actions.getSelfHostedRunnerForOrg).toHaveBeenCalledWith({ org: 'org-a', runner_id: 42 }); + expect(mockOctokit.actions.getSelfHostedRunnerForOrg).toHaveBeenCalledWith({ org: 'org-b', runner_id: 42 }); + expect(mockTerminateRunners).toHaveBeenCalledExactlyOnceWith(runners[0].id); + expect(mockUnmarkOrphan).toHaveBeenCalledExactlyOnceWith(runners[1].id); + vi.mocked(ghAuth.getStoredInstallationId).mockReset().mockResolvedValue(undefined); + }); + + it('de-registers a runner using its owning org and leaves another org busy', async () => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + const runners = ['org-a', 'org-b'].map((org) => createRunnerTestData(org, 'Org', 60, true, false, false, org)); + mockProviderRunners(runners); + mockOctokit.paginate.mockImplementation(async (_route, { org }) => + runners.filter((runner) => runner.owner === org).map((runner) => ({ id: 'same-id', name: runner.id })), + ); + mockOctokit.actions.getSelfHostedRunnerForOrg.mockImplementation(async ({ org }) => ({ + data: { busy: org === 'org-b', status: 'online' }, + })); + await scaleDown(); + expect(mockOctokit.actions.deleteSelfHostedRunnerFromOrg).toHaveBeenCalledExactlyOnceWith({ + org: 'org-a', + runner_id: 'same-id', + }); + expect(mockTerminateRunners).toHaveBeenCalledExactlyOnceWith(runners[0].id); + }); + }); + const endpoints = ['https://api.github.com', 'https://github.enterprise.something', 'https://companyname.ghe.com']; describe.each(endpoints)('for %s', (endpoint) => { diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts index 1e3e838aed..a35d2ec972 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts @@ -5,6 +5,7 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; import moment from 'moment'; +import { multiOrgEnabled } from '../github/multi-org'; import { createGithubAppAuth, createGithubInstallationAuth, @@ -39,7 +40,7 @@ async function getOrCreateOctokit(runner: RunnerInfo): Promise { const appIdx = ghAuthPre.appIndex; // Use the pre-configured installation ID when available (avoids an API call). - let installationId = await getStoredInstallationId(appIdx); + let installationId = multiOrgEnabled() ? undefined : await getStoredInstallationId(appIdx); if (installationId === undefined) { const githubClientPre = await createOctokitClient(ghAuthPre.token, ghesApiUrl, appIdx); installationId = @@ -289,6 +290,9 @@ async function evaluateAndRemoveRunners( const ownerTags = new Set(runners.map((runner) => runner.owner)); for (const ownerTag of ownerTags) { + if (multiOrgEnabled()) { + idleCounter = getIdleRunnerCount(scaleDownConfigs); + } const ownerRunners = runners .filter((runner) => runner.owner === ownerTag) .sort(evictionStrategy === 'oldest_first' ? oldestFirstStrategy : newestFirstStrategy); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts index d7a025fd88..70162edc7b 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts @@ -1,5 +1,5 @@ import type { Octokit } from '@octokit/rest'; -import { beforeEach, vi } from 'vitest'; +import { beforeEach, expect, it, vi } from 'vitest'; import { providerTypes } from '../test/compute-provider-contracts/provider-types'; import { defineScaleUpContractTests } from '../test/compute-provider-contracts/scale-up'; @@ -85,3 +85,34 @@ defineScaleUpContractTests({ resolveCapability: mockedResolveCapability, scaleUp, }); + +it('keeps mixed-org batches and maximum counts separate when multi-org is enabled', async () => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; + process.env.RUNNERS_MAXIMUM_COUNT = '2'; + const { provider, state } = computeProviders[0]; + mockedResolveCapability.mockReturnValue(() => provider); + provider.resolveLabelsForRunners.mockResolvedValue({ state, runnerLabels: [] }); + provider.getCurrentRunners.mockImplementation(async (_state, { runnerOwner }) => (runnerOwner === 'org-a' ? 2 : 0)); + provider.createRunners.mockResolvedValue({ + instances: ['runner-b'], + retryableErrorCount: 0, + nonRetryableErrorCount: 0, + }); + const messages = ['org-a', 'org-b'].map((org, i) => ({ + ...payloads[0], + repositoryOwner: org, + messageId: org, + installationId: i + 10, + })); + expect(await scaleUp(messages)).toEqual([]); + expect(provider.getCurrentRunners).toHaveBeenCalledWith(state, { runnerType: 'Org', runnerOwner: 'org-a' }); + expect(provider.getCurrentRunners).toHaveBeenCalledWith(state, { runnerType: 'Org', runnerOwner: 'org-b' }); + expect(provider.createRunners).toHaveBeenCalledTimes(1); + expect(provider.createRunners).toHaveBeenCalledWith( + expect.objectContaining({ + numberOfRunners: 1, + githubRunnerConfig: expect.objectContaining({ runnerType: 'Org', runnerOwner: 'org-b' }), + }), + ); +}); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts index d4e3889f19..1645ca5922 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -5,6 +5,7 @@ import { createStorageProviders, type StorageProviders } from '@aws-github-runne import { Octokit } from '@octokit/rest'; import yn from 'yn'; +import { multiOrgEnabled } from '../github/multi-org'; import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; import { controlPlaneProviderRegistry } from '../control-plane-providers'; import { @@ -96,7 +97,7 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.

global\_config\_github = {
app: {
key\_base64: "Base64-encoded GitHub App private key."
key\_base64\_ssm: "SSM parameter containing the Base64-encoded GitHub App private key."
key\_base64\_ssm.arn: "ARN of the SSM parameter containing the GitHub App private key."
key\_base64\_ssm.name: "Name of the SSM parameter containing the GitHub App private key."
id: "GitHub App ID."
id\_ssm: "SSM parameter containing the GitHub App ID."
id\_ssm.arn: "ARN of the SSM parameter containing the GitHub App ID."
id\_ssm.name: "Name of the SSM parameter containing the GitHub App ID."
webhook\_secret: "GitHub App webhook secret."
webhook\_secret\_ssm: "SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.arn: "ARN of the SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.name: "Name of the SSM parameter containing the GitHub App webhook secret."
}
additional\_apps: "Additional GitHub Apps used to distribute GitHub API requests."
additional\_apps.key\_base64: "Base64-encoded private key for an additional GitHub App."
additional\_apps.key\_base64\_ssm: "SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.arn: "ARN of the SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.name: "Name of the SSM parameter containing an additional App private key."
additional\_apps.id: "ID of an additional GitHub App."
additional\_apps.id\_ssm: "SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.arn: "ARN of the SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.name: "Name of the SSM parameter containing an additional GitHub App ID."
additional\_apps.installation\_id: "Optional installation ID for an additional GitHub App."
additional\_apps.installation\_id\_ssm: "SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.arn: "ARN of the SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.name: "Name of the SSM parameter containing an additional App installation ID."
enterprise\_server.url: "GitHub Enterprise Server URL."
enterprise\_server.ssl\_verify: "Whether to verify the GitHub Enterprise Server TLS certificate."
user\_agent: "User-Agent value sent with GitHub API requests."
} |
object({
app = optional(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}), null)
additional_apps = optional(list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
})
| `{}` | no | | [global\_config\_lambda](#input\_global\_config\_lambda) | Global Lambda configuration shared by all runner lanes.

global\_config\_lambda = {
artifact.s3.bucket: "S3 bucket containing Lambda deployment artifacts."
runtime: "Default Lambda runtime."
architecture: "Default Lambda instruction-set architecture."
principals: "Additional AWS principals allowed to invoke the Lambda functions."
principals.type: "Principal type, such as AWS account, service, or organization."
principals.identifiers: "Identifiers allowed for the principal type."
subnet\_ids: "Subnets used by Lambda functions."
security\_group\_ids: "Security groups attached to Lambda functions."
tags: "Tags applied to Lambda functions and related resources."
role.path: "IAM path used for Lambda execution roles."
role.permissions\_boundary: "Optional IAM permissions boundary ARN for Lambda execution roles."
} |
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.

global\_config\_observability = {
logs.level: "Log level for module resources."
logs.retention\_in\_days: "CloudWatch log retention period in days."
logs.kms\_key\_id: "KMS key ID used to encrypt CloudWatch log groups."
logs.class: "CloudWatch log group class."
logs.tags: "Tags applied to CloudWatch log groups."
tracing.mode: "Tracing mode used by instrumented resources."
tracing.capture\_http\_requests: "Whether HTTP requests are captured by tracing."
tracing.capture\_error: "Whether errors are captured by tracing."
metrics.enabled: "Whether module metrics are enabled."
metrics.namespace: "CloudWatch namespace used for module metrics."
metrics.metric.github\_app\_rate\_limit.enabled: "Whether GitHub App rate-limit metrics are emitted."
metrics.metric.job\_retry.enabled: "Whether job-retry metrics are emitted."
metrics.metric.spot\_termination\_warning.enabled: "Whether spot-termination warning metrics are emitted."
} |
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.

global\_config\_orchestration\_provider = {
webhook: {
queue\_selection\_strategy: "Strategy used to select the build queue for a webhook event."
eventbridge.enabled: "Whether EventBridge integration is enabled for webhook events."
eventbridge.accept\_events: "Event types accepted by the EventBridge integration."
matcher\_config\_parameter\_store\_tier: "SSM Parameter Store tier used for matcher configuration."
runner.boot\_time\_in\_minutes: "Expected runner boot time used by orchestration."
runner.ephemeral: "Whether runners created by the orchestration provider are ephemeral."
runner.jit\_config\_enabled: "Whether JIT runner configuration is enabled."
runner.maximum\_count: "Maximum number of runners that orchestration may create."
github.repository\_white\_list: "Repositories allowed to use the webhook configuration."
lambda.artifact.zip: "Local ZIP artifact used for orchestration Lambda functions."
lambda.artifact.s3.key: "S3 object key for the orchestration Lambda artifact."
lambda.artifact.s3.object\_version: "Optional S3 object version for the orchestration Lambda artifact."
lambda.scale.up.memory\_size: "Memory allocated to the scale-up Lambda."
lambda.scale.up.timeout: "Timeout in seconds for the scale-up Lambda."
lambda.scale.up.reserved\_concurrent\_executions: "Reserved concurrent executions for the scale-up Lambda."
lambda.scale.up.job\_queued\_check\_enabled: "Whether the scale-up Lambda checks queued jobs."
lambda.scale.up.event\_source\_mapping.batch\_size: "Maximum records passed to one scale-up Lambda invocation."
lambda.scale.up.event\_source\_mapping.maximum\_batching\_window\_in\_seconds: "Maximum time to batch records before invoking the scale-up Lambda."
lambda.scale.up.tags: "Tags applied to the scale-up Lambda."
lambda.scale.down.memory\_size: "Memory allocated to the scale-down Lambda."
lambda.scale.down.timeout: "Timeout in seconds for the scale-down Lambda."
lambda.scale.down.schedule\_expression: "Schedule expression for scale-down processing."
lambda.scale.down.minimum\_running\_time\_in\_minutes: "Minimum runner lifetime before scale-down."
lambda.scale.down.idle\_confirmation\_seconds: "Seconds a runner must consistently report not-busy before scale-down terminates it; 0 disables the confirmation window."
lambda.scale.down.idle\_config: "Scheduled minimum idle-runner pool settings."
lambda.scale.down.idle\_config.cron: "Cron expression defining when the idle-runner count applies."
lambda.scale.down.idle\_config.timeZone: "Time zone used to evaluate the idle-runner schedule."
lambda.scale.down.idle\_config.idleCount: "Minimum number of idle runners maintained during the schedule."
lambda.scale.down.idle\_config.evictionStrategy: "Strategy used when evicting idle runners."
lambda.scale.down.tags: "Tags applied to the scale-down Lambda."
lambda.webhook.artifact.zip: "Local ZIP artifact used for the webhook Lambda."
lambda.webhook.artifact.s3.key: "S3 object key for the webhook Lambda artifact."
lambda.webhook.artifact.s3.object\_version: "Optional S3 object version for the webhook Lambda artifact."
lambda.webhook.api\_gateway\_access\_log\_settings: "API Gateway access-log destination and format."
lambda.webhook.api\_gateway\_access\_log\_settings.destination\_arn: "ARN of the API Gateway access-log destination."
lambda.webhook.api\_gateway\_access\_log\_settings.format: "API Gateway access-log format."
lambda.webhook.memory\_size: "Memory allocated to the webhook Lambda."
lambda.webhook.timeout: "Timeout in seconds for the webhook Lambda."
lambda.webhook.tags: "Tags applied to the webhook Lambda."
lambda.pool.memory\_size: "Memory allocated to the pool Lambda."
lambda.pool.timeout: "Timeout in seconds for the pool Lambda."
lambda.pool.reserved\_concurrent\_executions: "Reserved concurrent executions for the pool Lambda."
lambda.pool.config: "Scheduled runner-pool size configuration."
lambda.pool.config.schedule\_expression: "Schedule expression for the pool size."
lambda.pool.config.schedule\_expression\_timezone: "Time zone used to evaluate the pool schedule."
lambda.pool.config.size: "Runner pool size applied by the schedule."
lambda.pool.include\_busy\_runners: "Whether busy runners are included in pool sizing."
lambda.pool.runner\_owner: "GitHub organization that owns the runner pool."
lambda.pool.tags: "Tags applied to the pool Lambda."
queue.delay\_webhook\_event: "Seconds a webhook event remains invisible in the build queue before processing."
queue.job\_queue\_retention\_in\_seconds: "Seconds a queued job is retained before it is purged."
queue.visibility\_timeout\_seconds: "Build queue visibility timeout in seconds."
queue.redrive\_build\_queue.enabled: "Whether the build queue dead-letter queue is enabled."
queue.redrive\_build\_queue.maxReceiveCount: "Maximum receives before a message is moved to the dead-letter queue."
queue.tags: "Tags applied to build queues."
queue.encryption.kms\_data\_key\_reuse\_period\_seconds: "KMS data-key reuse period for queue encryption."
queue.encryption.kms\_master\_key\_id: "KMS key ID used for queue encryption."
queue.encryption.sqs\_managed\_sse\_enabled: "Whether SQS-managed server-side encryption is enabled."
}
} |
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.

global\_config\_orchestration\_provider = {
webhook: {
queue\_selection\_strategy: "Strategy used to select the build queue for a webhook event."
eventbridge.enabled: "Whether EventBridge integration is enabled for webhook events."
eventbridge.accept\_events: "Event types accepted by the EventBridge integration."
matcher\_config\_parameter\_store\_tier: "SSM Parameter Store tier used for matcher configuration."
runner.boot\_time\_in\_minutes: "Expected runner boot time used by orchestration."
runner.ephemeral: "Whether runners created by the orchestration provider are ephemeral."
runner.jit\_config\_enabled: "Whether JIT runner configuration is enabled."
runner.maximum\_count: "Maximum number of runners that orchestration may create."
github.repository\_white\_list: "Repositories allowed to use the webhook configuration."
lambda.artifact.zip: "Local ZIP artifact used for orchestration Lambda functions."
lambda.artifact.s3.key: "S3 object key for the orchestration Lambda artifact."
lambda.artifact.s3.object\_version: "Optional S3 object version for the orchestration Lambda artifact."
lambda.scale.up.memory\_size: "Memory allocated to the scale-up Lambda."
lambda.scale.up.timeout: "Timeout in seconds for the scale-up Lambda."
lambda.scale.up.reserved\_concurrent\_executions: "Reserved concurrent executions for the scale-up Lambda."
lambda.scale.up.job\_queued\_check\_enabled: "Whether the scale-up Lambda checks queued jobs."
lambda.scale.up.event\_source\_mapping.batch\_size: "Maximum records passed to one scale-up Lambda invocation."
lambda.scale.up.event\_source\_mapping.maximum\_batching\_window\_in\_seconds: "Maximum time to batch records before invoking the scale-up Lambda."
lambda.scale.up.tags: "Tags applied to the scale-up Lambda."
lambda.scale.down.memory\_size: "Memory allocated to the scale-down Lambda."
lambda.scale.down.timeout: "Timeout in seconds for the scale-down Lambda."
lambda.scale.down.schedule\_expression: "Schedule expression for scale-down processing."
lambda.scale.down.minimum\_running\_time\_in\_minutes: "Minimum runner lifetime before scale-down."
lambda.scale.down.idle\_confirmation\_seconds: "Seconds a runner must consistently report not-busy before scale-down terminates it; 0 disables the confirmation window."
lambda.scale.down.idle\_config: "Scheduled minimum idle-runner pool settings."
lambda.scale.down.idle\_config.cron: "Cron expression defining when the idle-runner count applies."
lambda.scale.down.idle\_config.timeZone: "Time zone used to evaluate the idle-runner schedule."
lambda.scale.down.idle\_config.idleCount: "Minimum number of idle runners maintained during the schedule."
lambda.scale.down.idle\_config.evictionStrategy: "Strategy used when evicting idle runners."
lambda.scale.down.tags: "Tags applied to the scale-down Lambda."
lambda.webhook.artifact.zip: "Local ZIP artifact used for the webhook Lambda."
lambda.webhook.artifact.s3.key: "S3 object key for the webhook Lambda artifact."
lambda.webhook.artifact.s3.object\_version: "Optional S3 object version for the webhook Lambda artifact."
lambda.webhook.api\_gateway\_access\_log\_settings: "API Gateway access-log destination and format."
lambda.webhook.api\_gateway\_access\_log\_settings.destination\_arn: "ARN of the API Gateway access-log destination."
lambda.webhook.api\_gateway\_access\_log\_settings.format: "API Gateway access-log format."
lambda.webhook.memory\_size: "Memory allocated to the webhook Lambda."
lambda.webhook.timeout: "Timeout in seconds for the webhook Lambda."
lambda.webhook.tags: "Tags applied to the webhook Lambda."
lambda.pool.memory\_size: "Memory allocated to the pool Lambda."
lambda.pool.timeout: "Timeout in seconds for the pool Lambda."
lambda.pool.reserved\_concurrent\_executions: "Reserved concurrent executions for the pool Lambda."
lambda.pool.config: "Scheduled runner-pool size configuration."
lambda.pool.config.schedule\_expression: "Schedule expression for the pool size."
lambda.pool.config.schedule\_expression\_timezone: "Time zone used to evaluate the pool schedule."
lambda.pool.config.size: "Runner pool size applied by the schedule."
lambda.pool.include\_busy\_runners: "Whether busy runners are included in pool sizing."
lambda.pool.runner\_owner: "GitHub organization that owns the runner pool."
lambda.pool.tags: "Tags applied to the pool Lambda."
queue.delay\_webhook\_event: "Seconds a webhook event remains invisible in the build queue before processing."
queue.job\_queue\_retention\_in\_seconds: "Seconds a queued job is retained before it is purged."
queue.visibility\_timeout\_seconds: "Build queue visibility timeout in seconds."
queue.redrive\_build\_queue.enabled: "Whether the build queue dead-letter queue is enabled."
queue.redrive\_build\_queue.maxReceiveCount: "Maximum receives before a message is moved to the dead-letter queue."
queue.tags: "Tags applied to build queues."
queue.encryption.kms\_data\_key\_reuse\_period\_seconds: "KMS data-key reuse period for queue encryption."
queue.encryption.kms\_master\_key\_id: "KMS key ID used for queue encryption."
queue.encryption.sqs\_managed\_sse\_enabled: "Whether SQS-managed server-side encryption is enabled."
}
} |
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.

global\_config\_storage\_provider = {
aws.ssm.paths.root: "Root path for SSM parameters."
aws.ssm.paths.app: "Path segment for application parameters."
aws.ssm.paths.webhook: "Path segment for webhook parameters."
aws.ssm.paths.tokens: "Path segment for runner token parameters."
aws.ssm.paths.config: "Path segment for runner configuration parameters."
aws.ssm.kms\_key\_id: "KMS key ID used to encrypt SSM parameters."
aws.ssm.tags: "Tags applied to SSM resources."
aws.ssm.parameters.tags: "Tags applied to runner configuration parameters."
aws.ssm.housekeeper.schedule\_expression: "Schedule for the SSM parameter housekeeper."
aws.ssm.housekeeper.state: "EventBridge rule state for the SSM housekeeper."
aws.ssm.housekeeper.tags: "Tags applied to the SSM housekeeper resources."
aws.ssm.housekeeper.lambda.artifact.zip: "Local ZIP artifact used for the SSM housekeeper Lambda."
aws.ssm.housekeeper.lambda.artifact.s3.key: "S3 object key for the SSM housekeeper Lambda."
aws.ssm.housekeeper.lambda.artifact.s3.object\_version: "Optional S3 object version for the SSM housekeeper artifact."
aws.ssm.housekeeper.lambda.memory\_size: "Memory allocated to the SSM housekeeper Lambda."
aws.ssm.housekeeper.lambda.timeout: "Timeout in seconds for the SSM housekeeper Lambda."
aws.ssm.housekeeper.config.tokenPath: "Parameter path containing runner tokens to clean up."
aws.ssm.housekeeper.config.minimumDaysOld: "Minimum age in days before an old token is eligible for cleanup."
aws.ssm.housekeeper.config.dryRun: "Whether the SSM housekeeper reports cleanup without deleting parameters."
} |
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.

multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux, osx, windows)."
runner\_architecture: "The platform architecture of the runner instance\_type."
runner\_metadata\_options: "(Optional) Metadata options for the ec2 runner instances."
ami: "(Optional) AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place."
create\_service\_linked\_role\_spot: (Optional) create the serviced linked role for spot instances that is required by the scale-up lambda.
credit\_specification: "(Optional) The credit specification of the runner instance\_type. Can be unset, `standard` or `unlimited`.
delay\_webhook\_event: "The number of seconds the event accepted by the webhook is invisible on the queue before the scale up lambda will receive the event."
disable\_runner\_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)"
ebs\_optimized: "The EC2 EBS optimized configuration."
enable\_ephemeral\_runners: "Enable ephemeral runners, runners will only be used once."
enable\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
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."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
instance\_types: "List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
scale\_down\_idle\_confirmation\_seconds: "Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale, so a single not-busy reading is not sufficient evidence a runner is idle. 0 keeps the previous single-reading behaviour."
runner\_disable\_default\_labels: "Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_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."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
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."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
license\_specifications: "Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
job\_retry: "Experimental! Can be removed / changed without trigger a major release. Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app."
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)."
ssm\_ttl\_seconds.tokens: "Optional TTL in seconds for the SSM parameters holding the runner registration token / JIT config. When set, the parameters are created with an SSM expiration policy so SSM deletes them itself after the TTL passes. Requires the Advanced parameter tier for every token parameter, which incurs additional costs. Expiration is enforced asynchronously by SSM; the SSM housekeeper lambda remains as a backstop. Must be a positive number, and should comfortably exceed the runner boot time so the config does not expire before the instance reads it."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
# V2 contract
tags: "Tags applied to resources created for this runner configuration."
runner: "Runner settings such as the operating system, architecture, labels, hooks, runner group, name prefix, and IAM role configuration."
lambda: "Lambda settings such as runtime, architecture, networking, tags, and execution-role options for this runner configuration."
# Webhook, queue, and scale-up/scale-down orchestration settings.
orchestration\_provider: {
webhook: {
matcherConfig: "Label matching and dynamic-label policy used to route workflow jobs to this runner configuration."
runner: "Runner lifecycle settings including boot time, ephemeral mode, JIT configuration, and maximum runner count."
queue: "Build queue delay, retention, visibility timeout, redrive, and tags."
}
}
ssm: "SSM parameter paths, tags, and housekeeper settings for runner configuration storage."
observability: "Logging, tracing, and metric settings for the resources in this runner configuration."
# Compute settings for the runner provider.
compute\_provider: {
aws: {
ec2: "AWS EC2 runner settings, including AMI selection, instance types, capacity strategy, VPC and subnet placement, storage, user data, and runner access."
}
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "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 `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: if `allowed_keys` is set, only those keys are accepted; keys in `blocked_keys` are always rejected (cannot be used together with `allowed_keys`); keys in `restricted_keys` are allowed only when their value passes the rule; a key not listed anywhere is allowed. Schema: `{ allowed_keys = [], blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
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.

multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux, osx, windows)."
runner\_architecture: "The platform architecture of the runner instance\_type."
runner\_metadata\_options: "(Optional) Metadata options for the ec2 runner instances."
ami: "(Optional) AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place."
create\_service\_linked\_role\_spot: (Optional) create the serviced linked role for spot instances that is required by the scale-up lambda.
credit\_specification: "(Optional) The credit specification of the runner instance\_type. Can be unset, `standard` or `unlimited`.
delay\_webhook\_event: "The number of seconds the event accepted by the webhook is invisible on the queue before the scale up lambda will receive the event."
disable\_runner\_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)"
ebs\_optimized: "The EC2 EBS optimized configuration."
enable\_ephemeral\_runners: "Enable ephemeral runners, runners will only be used once."
enable\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
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."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
instance\_types: "List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
scale\_down\_idle\_confirmation\_seconds: "Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale, so a single not-busy reading is not sufficient evidence a runner is idle. 0 keeps the previous single-reading behaviour."
runner\_disable\_default\_labels: "Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_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."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
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."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
license\_specifications: "Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
job\_retry: "Experimental! Can be removed / changed without trigger a major release. Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app."
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)."
ssm\_ttl\_seconds.tokens: "Optional TTL in seconds for the SSM parameters holding the runner registration token / JIT config. When set, the parameters are created with an SSM expiration policy so SSM deletes them itself after the TTL passes. Requires the Advanced parameter tier for every token parameter, which incurs additional costs. Expiration is enforced asynchronously by SSM; the SSM housekeeper lambda remains as a backstop. Must be a positive number, and should comfortably exceed the runner boot time so the config does not expire before the instance reads it."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
# V2 contract
tags: "Tags applied to resources created for this runner configuration."
runner: "Runner settings such as the operating system, architecture, labels, hooks, runner group, name prefix, and IAM role configuration."
lambda: "Lambda settings such as runtime, architecture, networking, tags, and execution-role options for this runner configuration."
# Webhook, queue, and scale-up/scale-down orchestration settings.
orchestration\_provider: {
webhook: {
matcherConfig: "Label matching and dynamic-label policy used to route workflow jobs to this runner configuration."
runner: "Runner lifecycle settings including boot time, ephemeral mode, JIT configuration, and maximum runner count."
queue: "Build queue delay, retention, visibility timeout, redrive, and tags."
}
}
ssm: "SSM parameter paths, tags, and housekeeper settings for runner configuration storage."
observability: "Logging, tracing, and metric settings for the resources in this runner configuration."
# Compute settings for the runner provider.
compute\_provider: {
aws: {
ec2: "AWS EC2 runner settings, including AMI selection, instance types, capacity strategy, VPC and subnet placement, storage, user data, and runner access."
}
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "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 `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: if `allowed_keys` is set, only those keys are accepted; keys in `blocked_keys` are always rejected (cannot be used together with `allowed_keys`); keys in `restricted_keys` are allowed only when their value passes the rule; a key not listed anywhere is allowed. Schema: `{ allowed_keys = [], blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
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.

- `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `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.
- `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.
- `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides.
- `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive.
- `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive.
- `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket.
- `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive.
- `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency.
- `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode.
- `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation.
- `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags.
- `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `lambda.scale.down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. A value of `0` preserves the single-reading behavior.
- `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags.
- `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `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.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.
- `job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
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.

- `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `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.
- `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides.
- `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive.
- `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive.
- `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket.
- `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive.
- `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency.
- `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode.
- `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation.
- `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags.
- `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `lambda.scale.down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. A value of `0` preserves the single-reading behavior.
- `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags.
- `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `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.
- `job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
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.

- `prefix`: Prefix used to name job-retry resources.
- `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the job-retry Lambda.
- `lambda.architecture`: Instruction-set architecture used by the job-retry Lambda.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda.
- `lambda.timeout`: Lambda timeout and retry-queue visibility timeout in seconds.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the Lambda. Use `-1` for unreserved concurrency.
- `lambda.environment_variables`: Additional Lambda environment variables. Required job-retry variables override matching keys.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the job-retry Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the Lambda role.
- `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.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.
- `github.app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `github.app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `github.app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `github.app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `queue.encryption`: Server-side encryption configuration for the retry queue.
- `storage_provider.aws.ssm.kms_key_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and job-retry metric configuration.
- `tags.resources`: Tags for the job-retry Lambda role and component resources.
- `tags.lambda`: Tags for the job-retry Lambda function.
- `tags.log_group`: Tags for the job-retry log group.
- `tags.queue`: Tags for the retry queue.
- `tags.event_source_mapping`: Tags for the retry-queue event-source mapping. |
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.

- `prefix`: Prefix used to name job-retry resources.
- `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the job-retry Lambda.
- `lambda.architecture`: Instruction-set architecture used by the job-retry Lambda.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda.
- `lambda.timeout`: Lambda timeout and retry-queue visibility timeout in seconds.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the Lambda. Use `-1` for unreserved concurrency.
- `lambda.environment_variables`: Additional Lambda environment variables. Required job-retry variables override matching keys.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the job-retry Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the Lambda role.
- `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.
- `github.app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `github.app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `github.app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `github.app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `queue.encryption`: Server-side encryption configuration for the retry queue.
- `storage_provider.aws.ssm.kms_key_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and job-retry metric configuration.
- `tags.resources`: Tags for the job-retry Lambda role and component resources.
- `tags.lambda`: Tags for the job-retry Lambda function.
- `tags.log_group`: Tags for the job-retry log group.
- `tags.queue`: Tags for the retry queue.
- `tags.event_source_mapping`: Tags for the retry-queue event-source mapping. |
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.

- `lambda`: Pool Lambda runtime and deployment configuration.
- `lambda.log_level`: Logging level used by the pool Lambda.
- `lambda.logging_retention_in_days`: Number of days to retain events in the pool Lambda log group.
- `lambda.logging_kms_key_id`: KMS key ID used to encrypt the pool Lambda log group.
- `lambda.log_class`: CloudWatch Logs class for the pool Lambda log group.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use -1 for no reservation.
- `lambda.s3_bucket`: S3 bucket containing the pool Lambda deployment package.
- `lambda.s3_key`: S3 key of the pool Lambda deployment package.
- `lambda.s3_object_version`: S3 object version of the pool Lambda deployment package.
- `lambda.security_group_ids`: Security group IDs associated with the pool Lambda.
- `lambda.runtime`: AWS Lambda runtime used by the pool Lambda.
- `lambda.architecture`: AWS Lambda architecture used by the pool Lambda.
- `lambda.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.timeout`: Pool Lambda timeout in seconds.
- `lambda.zip`: Local path to the pool Lambda deployment package when S3 is not used.
- `lambda.subnet_ids`: Subnet IDs in which the pool Lambda runs.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `tags`: Common tags added to pool resources.
- `ghes`: GitHub Enterprise Server connection configuration.
- `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub.
- `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate.
- `github_app_parameters`: SSM parameter metadata for the primary and additional GitHub App credentials.
- `github_app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `github_app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `github_app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `github_app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `runner`: Runner registration configuration used by the pool Lambda.
- `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled.
- `runner.ephemeral`: Whether runners register as ephemeral runners.
- `runner.enable_jit_config`: Whether runners use just-in-time registration configuration.
- `runner.labels`: Labels assigned to runners created by the pool Lambda.
- `runner.group_name`: GitHub runner group assigned to runners created by the pool Lambda.
- `runner.name_prefix`: Prefix used for runner names.
- `runner.pool_owner`: GitHub organization or repository that owns the runner pool.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `prefix`: Prefix used to name pool resources.
- `pool`: Scheduled pool targets.
- `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target.
- `pool[*].schedule_expression_timezone`: Time zone used to evaluate the schedule expression.
- `pool[*].size`: Desired runner count for the scheduled pool target.
- `include_busy_runners`: Whether busy runners count toward the desired pool size.
- `role_permissions_boundary`: Permissions boundary applied to IAM roles created for the pool.
- `role_path`: IAM path applied to roles created for the pool.
- `lambda_tags`: Tags added specifically to the pool Lambda function, overriding common tags with the same key.
- `log_group_tags`: Tags added specifically to the pool Lambda log group, overriding common tags with the same key.
- `user_agent`: User-Agent header used for GitHub API requests. |
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.

- `lambda`: Pool Lambda runtime and deployment configuration.
- `lambda.log_level`: Logging level used by the pool Lambda.
- `lambda.logging_retention_in_days`: Number of days to retain events in the pool Lambda log group.
- `lambda.logging_kms_key_id`: KMS key ID used to encrypt the pool Lambda log group.
- `lambda.log_class`: CloudWatch Logs class for the pool Lambda log group.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use -1 for no reservation.
- `lambda.s3_bucket`: S3 bucket containing the pool Lambda deployment package.
- `lambda.s3_key`: S3 key of the pool Lambda deployment package.
- `lambda.s3_object_version`: S3 object version of the pool Lambda deployment package.
- `lambda.security_group_ids`: Security group IDs associated with the pool Lambda.
- `lambda.runtime`: AWS Lambda runtime used by the pool Lambda.
- `lambda.architecture`: AWS Lambda architecture used by the pool Lambda.
- `lambda.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.timeout`: Pool Lambda timeout in seconds.
- `lambda.zip`: Local path to the pool Lambda deployment package when S3 is not used.
- `lambda.subnet_ids`: Subnet IDs in which the pool Lambda runs.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `tags`: Common tags added to pool resources.
- `ghes`: GitHub Enterprise Server connection configuration.
- `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub.
- `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate.
- `github_app_parameters`: SSM parameter metadata for the primary and additional GitHub App credentials.
- `github_app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `github_app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `github_app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `github_app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `runner`: Runner registration configuration used by the pool Lambda.
- `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled.
- `runner.ephemeral`: Whether runners register as ephemeral runners.
- `runner.enable_jit_config`: Whether runners use just-in-time registration configuration.
- `runner.labels`: Labels assigned to runners created by the pool Lambda.
- `runner.group_name`: GitHub runner group assigned to runners created by the pool Lambda.
- `runner.name_prefix`: Prefix used for runner names.
- `runner.pool_owner`: GitHub organization or repository that owns the runner pool.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `prefix`: Prefix used to name pool resources.
- `pool`: Scheduled pool targets.
- `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target.
- `pool[*].schedule_expression_timezone`: Time zone used to evaluate the schedule expression.
- `pool[*].size`: Desired runner count for the scheduled pool target.
- `include_busy_runners`: Whether busy runners count toward the desired pool size.
- `role_permissions_boundary`: Permissions boundary applied to IAM roles created for the pool.
- `role_path`: IAM path applied to roles created for the pool.
- `lambda_tags`: Tags added specifically to the pool Lambda function, overriding common tags with the same key.
- `log_group_tags`: Tags added specifically to the pool Lambda log group, overriding common tags with the same key.
- `user_agent`: User-Agent header used for GitHub API requests. |
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.

- `type`: Compute provider type passed to scheduled pool invocations.
- `environment_variables`: Provider-specific environment variables added to the pool Lambda.
- `iam_policy_json`: Provider-specific IAM policy document merged into the pool Lambda policy.
- `managed_policy_enabled`: Whether to attach a provider-specific managed IAM policy to the pool Lambda role.
- `managed_policy_arn`: ARN of the provider-specific managed IAM policy to attach when enabled. |
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.

- `mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `capture_error`: Whether Powertools tracing captures errors as tracing metadata. |
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..1359fd32e2 100644 --- a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl +++ b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl @@ -293,3 +293,60 @@ 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] +} diff --git a/modules/orchestration-providers/webhook/pool/variables.tf b/modules/orchestration-providers/webhook/pool/variables.tf index 8ccb8fa16d..14b7369c09 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,13 @@ 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 : can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]*$", pool.org == null ? var.config.runner.pool_owner : pool.org)) + ]) + error_message = "Multi-org pools require an organization login in each schedule's org or the default pool owner." + } } 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.

- `prefix`: Prefix used to name scaling resources.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by both scaling Lambdas.
- `lambda.architecture`: Instruction-set architecture used by both scaling Lambdas.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the scaling Lambda roles.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the scaling Lambda roles.
- `lambda.role.principals`: Additional principals allowed to assume the scaling Lambda roles.
- `runner.os`: Runner operating system used for the minimum-runtime default.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Enables or disables just-in-time runner configuration.
- `runner.labels`: Labels supplied when a runner is registered.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `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.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.
- `github.app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `github.app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `github.app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `github.app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `storage_provider.aws.ssm.token_path`: Parameter Store path used for registration tokens.
- `storage_provider.aws.ssm.token_path_arn`: ARN of the Parameter Store path used for registration tokens.
- `storage_provider.aws.ssm.config_path`: Parameter Store path used for persistent runner configuration.
- `storage_provider.aws.ssm.config_path_arn`: ARN of the persistent runner configuration path.
- `storage_provider.aws.ssm.kms_key_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `storage_provider.aws.ssm.parameter_store_tags`: JSON-encoded tags applied to parameters created at runtime.
- `observability.logs`: Shared logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and GitHub rate-limit metric configuration.
- `scale_up`: Scale-up Lambda sizing, concurrency, queued-job behavior, and resolved resource tag maps.
- `scale_up.tags.resources`: Tags for the scale-up IAM role and other component resources.
- `scale_up.tags.lambda`: Tags for the scale-up Lambda function.
- `scale_up.tags.log_group`: Tags for the scale-up log group.
- `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping.
- `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps.
- `scale_down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale (it can read false for a runner that is actively executing a job), so a single not-busy reading is not sufficient evidence a runner is idle. Set to at least one scale-down schedule interval to require two consecutive not-busy evaluations; a busy reading resets the window. 0 keeps the previous single-reading behaviour.
- `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule.
- `scale_down.tags.lambda`: Tags for the scale-down Lambda function.
- `scale_down.tags.log_group`: Tags for the scale-down log group.
- `job_retry.enabled`: Enables publishing retry checks from scale-up.
- `job_retry.queue`: Retry queue ARN and URL. Required when job retry is enabled.
- `job_retry.max_attempts`: Maximum queued-job retry attempts.
- `job_retry.delay_in_seconds`: Initial delay before checking the queued job.
- `job_retry.delay_backoff`: Multiplier applied to subsequent delays. |
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.

- `prefix`: Prefix used to name scaling resources.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by both scaling Lambdas.
- `lambda.architecture`: Instruction-set architecture used by both scaling Lambdas.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the scaling Lambda roles.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the scaling Lambda roles.
- `lambda.role.principals`: Additional principals allowed to assume the scaling Lambda roles.
- `runner.os`: Runner operating system used for the minimum-runtime default.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Enables or disables just-in-time runner configuration.
- `runner.labels`: Labels supplied when a runner is registered.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `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.
- `github.app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `github.app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `github.app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `github.app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `storage_provider.aws.ssm.token_path`: Parameter Store path used for registration tokens.
- `storage_provider.aws.ssm.token_path_arn`: ARN of the Parameter Store path used for registration tokens.
- `storage_provider.aws.ssm.config_path`: Parameter Store path used for persistent runner configuration.
- `storage_provider.aws.ssm.config_path_arn`: ARN of the persistent runner configuration path.
- `storage_provider.aws.ssm.kms_key_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `storage_provider.aws.ssm.parameter_store_tags`: JSON-encoded tags applied to parameters created at runtime.
- `observability.logs`: Shared logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and GitHub rate-limit metric configuration.
- `scale_up`: Scale-up Lambda sizing, concurrency, queued-job behavior, and resolved resource tag maps.
- `scale_up.tags.resources`: Tags for the scale-up IAM role and other component resources.
- `scale_up.tags.lambda`: Tags for the scale-up Lambda function.
- `scale_up.tags.log_group`: Tags for the scale-up log group.
- `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping.
- `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps.
- `scale_down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale (it can read false for a runner that is actively executing a job), so a single not-busy reading is not sufficient evidence a runner is idle. Set to at least one scale-down schedule interval to require two consecutive not-busy evaluations; a busy reading resets the window. 0 keeps the previous single-reading behaviour.
- `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule.
- `scale_down.tags.lambda`: Tags for the scale-down Lambda function.
- `scale_down.tags.log_group`: Tags for the scale-down log group.
- `job_retry.enabled`: Enables publishing retry checks from scale-up.
- `job_retry.queue`: Retry queue ARN and URL. Required when job retry is enabled.
- `job_retry.max_attempts`: Maximum queued-job retry attempts.
- `job_retry.delay_in_seconds`: Initial delay before checking the queued job.
- `job_retry.delay_backoff`: Multiplier applied to subsequent delays. |
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.

- `type`: Compute-provider discriminator supplied to both Lambdas.
- `scale_up.environment_variables`: Provider-specific scale-up environment variables.
- `scale_up.iam_policy_json`: Provider-specific IAM policy merged into the common scale-up policy.
- `scale_up.additional_iam_policy_json`: Optional additional provider policy attached separately to the scale-up role.
- `scale_up.managed_policy`: Optional provider-managed policy attachment. Object presence controls attachment creation.
- `scale_up.managed_policy.arn`: ARN of the provider-managed policy. The ARN may remain unknown until apply.
- `scale_down.environment_variables`: Provider-specific scale-down environment variables.
- `scale_down.iam_policy_json`: Provider-specific IAM policy merged into the common scale-down policy. |
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.

- `app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `user_agent`: Optional User-Agent value added to GitHub API requests. |
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.

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the control-plane Lambda functions. Supported values are `arm64` and `x86_64`.
- `subnet_ids`: Subnets used for Lambda VPC configuration.
- `security_group_ids`: Security groups used for Lambda VPC configuration.
- `tags`: Shared tags applied to Lambda function resources only. These override module-level `tags`; component `tags` override this map when keys conflict.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `role.path`: IAM path for module-managed Lambda execution roles. Defaults to a path derived from `prefix`.
- `role.permissions_boundary`: Permissions-boundary ARN applied to module-managed Lambda execution roles. |
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.

- `logs.level`: Application log level supplied to the control-plane functions.
- `logs.retention_in_days`: CloudWatch Logs retention period.
- `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt CloudWatch log groups.
- `logs.class`: CloudWatch log-group class. Supported values are `STANDARD` and `INFREQUENT_ACCESS`.
- `logs.tags`: Shared tags for CloudWatch log groups. These override module-level `tags`; component `tags` override this map when keys conflict.
- `tracing.mode`: Optional Lambda active-tracing mode. Null disables X-Ray tracing configuration.
- `tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `tracing.capture_error`: Enables error capture in the tracing helper.
- `metrics.enabled`: Enables module-emitted metrics.
- `metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `metrics.metric.github_app_rate_limit.enabled`: Emits GitHub App rate-limit metrics.
- `metrics.metric.job_retry.enabled`: Emits job-retry metrics.
- `metrics.metric.spot_termination_warning.enabled`: Emits spot-termination warning metrics where supported. |
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.

- `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. Future providers can be added as sibling blocks without moving this contract.
- `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`.
- `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`.
- `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.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.
- `webhook.queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. The default is `{}`.
- `webhook.lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. Set at most one of `zip` or `s3`; no selection uses the packaged runner archive.
- `webhook.lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. The default is null.
- `webhook.lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning, selecting it requires a non-null common bucket, and the default is null.
- `webhook.lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `webhook.lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. The default is null.
- `webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. The default is `512`.
- `webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. The default is `0`.
- `webhook.lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. The default is `512`.
- `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`.
- `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `webhook.lambda.scale.down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. The default is `0`, which preserves the single-reading behavior.
- `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`.
- `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `webhook.lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. The default is `512`.
- `webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.pool.config`: Scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `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.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 `{}`.
- `webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. The default is `false`.
- `webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. The default is `300`.
- `webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. The default is `2`.
- `webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. The default is `1`.
- `webhook.job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`.
- `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`. |
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.

- `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. Future providers can be added as sibling blocks without moving this contract.
- `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`.
- `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`.
- `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.
- `webhook.queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. The default is `{}`.
- `webhook.lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. Set at most one of `zip` or `s3`; no selection uses the packaged runner archive.
- `webhook.lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. The default is null.
- `webhook.lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning, selecting it requires a non-null common bucket, and the default is null.
- `webhook.lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `webhook.lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. The default is null.
- `webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. The default is `512`.
- `webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. The default is `0`.
- `webhook.lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. The default is `512`.
- `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`.
- `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `webhook.lambda.scale.down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. The default is `0`, which preserves the single-reading behavior.
- `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`.
- `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `webhook.lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. The default is `512`.
- `webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.pool.config`: Scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `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 `{}`.
- `webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. The default is `false`.
- `webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. The default is `300`.
- `webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. The default is `2`.
- `webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. The default is `1`.
- `webhook.job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`.
- `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`. |
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.

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture, such as `x64` or `arm64`.
- `disable_default_labels`: Prevents GitHub's default self-hosted, operating-system, and architecture labels from being registered.
- `labels`: Complete set of labels supplied to the control-plane functions.
- `group_name`: GitHub runner group used during registration.
- `name_prefix`: Prefix added to registered runner names.
- `run_as_root`: Runs the runner service as root when supported by the compute provider.
- `run_as`: Operating-system user used when `run_as_root` is false.
- `auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `tags`: Additional tags for common runner resources, currently the managed runner IAM role. These override module-level `tags` with the same key.
- `hooks.job_started`: Script content installed as the runner job-started hook.
- `hooks.job_completed`: Script content installed as the runner job-completed hook.
- `iam.role.arn`: ARN of an externally managed runner role. When set, this module does not create or modify that role.
- `iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role.
- `iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `iam.path`: IAM path for the module-managed runner role. Defaults to a path derived from `prefix`.
- `iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role. |
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.

- `storage_provider.aws.ssm.paths.root`: Root Parameter Store path for this runner configuration.
- `storage_provider.aws.ssm.paths.tokens`: Path segment under `paths.root` used for registration tokens and just-in-time configuration.
- `storage_provider.aws.ssm.paths.config`: Path segment under `paths.root` used for persistent runner configuration.
- `storage_provider.aws.ssm.kms_key_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters.
- `storage_provider.aws.ssm.tags`: Shared tags for SSM-related resources. These override module-level `tags` and are inherited by parameter and housekeeper resources.
- `storage_provider.aws.ssm.parameters.tags`: Tags for Terraform-managed runner configuration parameters and temporary parameters created by the scale-up and pool Lambdas. These override module-level and `storage_provider.aws.ssm.tags` values with the same key.
- `storage_provider.aws.ssm.housekeeper.schedule_expression`: EventBridge schedule expression that invokes the SSM housekeeper.
- `storage_provider.aws.ssm.housekeeper.state`: EventBridge rule state, such as `ENABLED` or `DISABLED`.
- `storage_provider.aws.ssm.housekeeper.tags`: Tags for housekeeper resources, including the Lambda function, log group, EventBridge rule, and IAM role. These override module-level, `storage_provider.aws.ssm.tags`, shared Lambda, and shared log tags when keys conflict.
- `storage_provider.aws.ssm.housekeeper.lambda.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact.
- `storage_provider.aws.ssm.housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `storage_provider.aws.ssm.housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket.
- `storage_provider.aws.ssm.housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `storage_provider.aws.ssm.housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive.
- `storage_provider.aws.ssm.housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `storage_provider.aws.ssm.housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `storage_provider.aws.ssm.housekeeper.config.tokenPath`: Parameter Store token path cleaned by the housekeeper. When omitted, the configured runner token path is used.
- `storage_provider.aws.ssm.housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `storage_provider.aws.ssm.housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. |
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.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
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.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`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.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
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/variables.tf b/modules/runners/pool/variables.tf index 20e5f80c60..88dda8ccb6 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,13 @@ variable "config" { lambda_tags = map(string) user_agent = string }) + + validation { + condition = !var.config.enable_multi_org_runners || alltrue([ + for pool in var.config.pool : can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]*$", pool.org == null ? var.config.runner.pool_owner : pool.org)) + ]) + error_message = "Multi-org pools require an organization login in each schedule's org or the default pool owner." + } } 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 = [] From 5f67087428e658542af7ef87222acfc610e9f9cb Mon Sep 17 00:00:00 2001 From: Guilherme Caulada Date: Mon, 14 Sep 2026 10:35:16 -0300 Subject: [PATCH 2/4] fix: validate multi-org pool organization logins --- .../control-plane/src/pool/pool.test.ts | 21 ++ .../functions/control-plane/src/pool/pool.ts | 11 +- .../webhook/pool/tests/provider.tftest.hcl | 116 +++++++++++ .../webhook/pool/variables.tf | 8 +- modules/runners/pool/tests/login.tftest.hcl | 189 ++++++++++++++++++ modules/runners/pool/variables.tf | 8 +- 6 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 modules/runners/pool/tests/login.tftest.hcl diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index 9d17fa4be0..680d0d42fe 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -404,6 +404,27 @@ describe('multi-org pools', () => { expect(mockedAppAuth).not.toHaveBeenCalled(); }); + describe.each(['event.org', 'RUNNER_OWNER'])('login validation for %s', (source) => { + it.each(['org-', 'org--name', '-org', 'a'.repeat(40), 'org_name', 'org\n'])( + 'rejects invalid login %j before GitHub calls', + async (org) => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + if (source === 'RUNNER_OWNER') process.env.RUNNER_OWNER = org; + await expect(adjust({ poolSize: 3, org: source === 'event.org' ? org : undefined })).rejects.toThrow( + '1-39 alphanumeric characters or single hyphens', + ); + expect(mockedAppAuth).not.toHaveBeenCalled(); + }, + ); + + it.each(['a', 'Org-1', 'org-a-b', 'a'.repeat(39), `${'a'.repeat(37)}-1`])('accepts valid login %s', async (org) => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + if (source === 'RUNNER_OWNER') process.env.RUNNER_OWNER = org; + await adjust({ poolSize: 3, org: source === 'event.org' ? org : undefined }); + expect(githubClient.apps.getOrgInstallation).toHaveBeenCalledWith({ org }); + }); + }); + it('ignores event.org when multi-org is disabled', async () => { process.env.ENABLE_MULTI_ORG_RUNNERS = 'false'; await adjust({ poolSize: 3, org: 'org-b' }); diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index 0a1aa6794f..73e8ad5c37 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -39,8 +39,15 @@ export async function adjust(event: PoolEvent): Promise { const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeral }); const disableAutoUpdate = yn(process.env.DISABLE_RUNNER_AUTOUPDATE, { default: false }); const runnerOwner = multiOrgEnabled() ? (event.org ?? process.env.RUNNER_OWNER) : process.env.RUNNER_OWNER; - if (multiOrgEnabled() && (!runnerOwner || !/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(runnerOwner))) { - throw new Error('Multi-org pools require an organization in event.org or RUNNER_OWNER'); + if ( + multiOrgEnabled() && + (!runnerOwner || + runnerOwner.length > 39 || + runnerOwner.match(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/)?.[0] !== runnerOwner) + ) { + throw new Error( + 'Multi-org pools require an organization in event.org or RUNNER_OWNER: 1-39 alphanumeric characters or single hyphens, with no leading or trailing hyphen', + ); } // -1 disables the maximum check, matching the scale-up lambda's semantics. Defaults to unlimited // when unset so the pool keeps its previous behavior on stacks that do not provide the variable. diff --git a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl index 1359fd32e2..4bf28aa602 100644 --- a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl +++ b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl @@ -350,3 +350,119 @@ run "multi_org_pool_rejects_empty_override" { } 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 14b7369c09..51096500ae 100644 --- a/modules/orchestration-providers/webhook/pool/variables.tf +++ b/modules/orchestration-providers/webhook/pool/variables.tf @@ -115,9 +115,13 @@ variable "config" { validation { condition = !var.config.enable_multi_org_runners || alltrue([ - for pool in var.config.pool : can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]*$", pool.org == null ? var.config.runner.pool_owner : pool.org)) + 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." + 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." } } 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 88dda8ccb6..7afacb18a4 100644 --- a/modules/runners/pool/variables.tf +++ b/modules/runners/pool/variables.tf @@ -87,9 +87,13 @@ variable "config" { validation { condition = !var.config.enable_multi_org_runners || alltrue([ - for pool in var.config.pool : can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]*$", pool.org == null ? var.config.runner.pool_owner : pool.org)) + 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." + 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." } } From 0a23dfdda3a79901af0e860fbd87eae24b1104b8 Mon Sep 17 00:00:00 2001 From: Guilherme Caulada Date: Mon, 14 Sep 2026 11:02:22 -0300 Subject: [PATCH 3/4] fix: normalize organization identity across runner lifecycle --- docs/multi-org.md | 4 +- .../control-plane/src/github/multi-org.ts | 5 +++ .../control-plane/src/github/octokit.ts | 4 +- .../control-plane/src/pool/pool.test.ts | 22 ++++++++++- .../functions/control-plane/src/pool/pool.ts | 6 ++- .../src/scale-runners/github-runner.ts | 4 +- .../src/scale-runners/job-retry.test.ts | 20 ++++++++++ .../src/scale-runners/job-retry.ts | 3 +- .../src/scale-runners/scale-down.test.ts | 23 ++++++++++- .../src/scale-runners/scale-down.ts | 12 ++++-- .../scale-runners/scale-up-contract.test.ts | 38 ++++++++++++++++++- .../src/scale-runners/scale-up.ts | 13 +++++-- .../aws/ec2/src/control-plane/pool.test.ts | 5 ++- .../aws/ec2/src/control-plane/pool.ts | 6 +-- .../ec2/src/control-plane/scale-up.test.ts | 16 ++++++++ .../aws/ec2/src/control-plane/scale-up.ts | 4 +- .../aws/ec2/src/runners.test.ts | 27 +++++++++++++ .../compute-providers/aws/ec2/src/runners.ts | 9 ++++- lambdas/libs/compute-providers/core/index.ts | 6 +++ 19 files changed, 199 insertions(+), 28 deletions(-) diff --git a/docs/multi-org.md b/docs/multi-org.md index d0246a788f..03eef22cb0 100644 --- a/docs/multi-org.md +++ b/docs/multi-org.md @@ -28,7 +28,7 @@ pool_config = [ ] ``` -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 as returned by GitHub, rather than its display name. Without the flag, `org` is ignored and the existing default owner is used. +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. @@ -39,7 +39,7 @@ For the legacy `modules/multi-runner` interface, set `enable_multi_org_runners` - 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. Other compute providers use the equivalent owner/type fields in their provider contract. +- 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. 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 index e8177e3955..5dce76cd9e 100644 --- a/lambdas/functions/control-plane/src/github/multi-org.ts +++ b/lambdas/functions/control-plane/src/github/multi-org.ts @@ -4,3 +4,8 @@ import yn from 'yn'; 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.ts b/lambdas/functions/control-plane/src/github/octokit.ts index cb9641d5bf..0015c3ebd8 100644 --- a/lambdas/functions/control-plane/src/github/octokit.ts +++ b/lambdas/functions/control-plane/src/github/octokit.ts @@ -7,7 +7,7 @@ import { createOctokitClient, getStoredInstallationId, } from './auth'; -import { multiOrgEnabled } from './multi-org'; +import { multiOrgEnabled, normalizeOrganization } from './multi-org'; const logger = createChildLogger('octokit'); @@ -28,7 +28,7 @@ async function resolveInstallationIdFromApi( return enableOrgLevel ? ( await githubClient.apps.getOrgInstallation({ - org: payload.repositoryOwner, + org: normalizeOrganization(payload.repositoryOwner), }) ).data.id : ( diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index 680d0d42fe..d5a97d7e46 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -379,6 +379,7 @@ describe('multi-org pools', () => { environment: process.env.ENVIRONMENT, runnerOwner: org, runnerType: 'Org', + runnerOwnerIgnoreCase: true, }); expect(poolProvider.createRunners).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -421,7 +422,7 @@ describe('multi-org pools', () => { process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; if (source === 'RUNNER_OWNER') process.env.RUNNER_OWNER = org; await adjust({ poolSize: 3, org: source === 'event.org' ? org : undefined }); - expect(githubClient.apps.getOrgInstallation).toHaveBeenCalledWith({ org }); + expect(githubClient.apps.getOrgInstallation).toHaveBeenCalledWith({ org: org.toLowerCase() }); }); }); @@ -436,3 +437,22 @@ describe('multi-org pools', () => { ); }); }); + +it('normalizes the pool owner for registration and capacity lookup only in multi-org mode', async () => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + await adjust({ poolSize: 3, org: 'Org-A' }); + expect(poolProvider.listRunners).toHaveBeenCalledWith( + expect.objectContaining({ runnerOwner: 'org-a', runnerOwnerIgnoreCase: true }), + ); + expect(poolProvider.createRunners).toHaveBeenCalledWith( + expect.objectContaining({ githubRunnerConfig: expect.objectContaining({ runnerOwner: 'org-a' }) }), + ); + process.env.ENABLE_MULTI_ORG_RUNNERS = 'false'; + process.env.RUNNER_OWNER = 'Org-A'; + await adjust({ poolSize: 3 }); + expect(poolProvider.listRunners).toHaveBeenLastCalledWith({ + environment: process.env.ENVIRONMENT, + runnerOwner: 'Org-A', + runnerType: 'Org', + }); +}); diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index 73e8ad5c37..8cfa451fc5 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -4,7 +4,7 @@ import { resolveComputeProviderType } from '@aws-github-runner/compute-providers import { createStorageProviders, type StorageProviders } from '@aws-github-runner/storage-providers'; import yn from 'yn'; -import { multiOrgEnabled } from '../github/multi-org'; +import { multiOrgEnabled, normalizeOrganization } from '../github/multi-org'; import { createGithubAppAuth, createGithubInstallationAuth, @@ -38,7 +38,7 @@ export async function adjust(event: PoolEvent): Promise { const ephemeral = yn(process.env.ENABLE_EPHEMERAL_RUNNERS, { default: false }); const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeral }); const disableAutoUpdate = yn(process.env.DISABLE_RUNNER_AUTOUPDATE, { default: false }); - const runnerOwner = multiOrgEnabled() ? (event.org ?? process.env.RUNNER_OWNER) : process.env.RUNNER_OWNER; + let runnerOwner = multiOrgEnabled() ? (event.org ?? process.env.RUNNER_OWNER) : process.env.RUNNER_OWNER; if ( multiOrgEnabled() && (!runnerOwner || @@ -49,6 +49,7 @@ export async function adjust(event: PoolEvent): Promise { 'Multi-org pools require an organization in event.org or RUNNER_OWNER: 1-39 alphanumeric characters or single hyphens, with no leading or trailing hyphen', ); } + if (multiOrgEnabled()) runnerOwner = normalizeOrganization(runnerOwner); // -1 disables the maximum check, matching the scale-up lambda's semantics. Defaults to unlimited // when unset so the pool keeps its previous behavior on stacks that do not provide the variable. const maximumRunners = parseInt(process.env.RUNNERS_MAXIMUM_COUNT || '-1'); @@ -77,6 +78,7 @@ export async function adjust(event: PoolEvent): Promise { environment, runnerOwner, runnerType: 'Org', + ...(multiOrgEnabled() ? { runnerOwnerIgnoreCase: true } : {}), }); const numberOfRunnersInPool = computeProvider.countAvailableRunners(poolRunners, runnerStatusses, includeBusyRunners); diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index 9980cfb5f1..69a32e0686 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -9,7 +9,7 @@ import { import { Octokit } from '@octokit/rest'; import type { ResponseHeaders } from '@octokit/types'; -import { multiOrgEnabled } from '../github/multi-org'; +import { multiOrgEnabled, normalizeOrganization } from '../github/multi-org'; import { getStoredInstallationId } from '../github/auth'; import { metricGitHubAppRateLimit } from '../github/rate-limit'; import { ActionRequestMessage, CreateGitHubRunnerConfig, EphemeralRunnerConfig, RunnerGroup } from './types'; @@ -93,7 +93,7 @@ export async function resolveInstallationId( return enableOrgLevel ? ( await githubAppClient.apps.getOrgInstallation({ - org: payload.repositoryOwner, + org: normalizeOrganization(payload.repositoryOwner), }) ).data.id : ( diff --git a/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts b/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts index c4ff1e5d76..6dddce6295 100644 --- a/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts @@ -361,3 +361,23 @@ describe('Test job retry handler (batch processing)', () => { expect(publishMessage).toHaveBeenCalledTimes(2); }); }); + +it.each([true, false])('normalizes retry ownership only in multi-org mode (%s)', async (enabled) => { + process.env.ENABLE_MULTI_ORG_RUNNERS = String(enabled); + process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + mockOctokit.actions.getJobForWorkflowRun.mockResolvedValue({ data: { status: 'queued' }, headers: {} }); + const payload: ActionRequestMessageRetry = { + id: 1, + eventType: 'workflow_job', + installationId: 1, + repositoryOwner: 'Org-A', + repositoryName: 'repo', + repoOwnerType: 'Organization', + }; + await checkAndRetryJob(payload); + const owner = enabled ? 'org-a' : 'Org-A'; + expect(mockCreateOctokitClient).toHaveBeenCalledWith('', true, expect.objectContaining({ repositoryOwner: owner })); + expect(mockOctokit.actions.getJobForWorkflowRun).toHaveBeenCalledWith(expect.objectContaining({ owner })); + expect(JSON.parse(vi.mocked(publishMessage).mock.calls[0][0]).repositoryOwner).toBe(owner); + expect(payload.repositoryOwner).toBe('Org-A'); +}); diff --git a/lambdas/functions/control-plane/src/scale-runners/job-retry.ts b/lambdas/functions/control-plane/src/scale-runners/job-retry.ts index 6525820fdc..53b96a79a5 100644 --- a/lambdas/functions/control-plane/src/scale-runners/job-retry.ts +++ b/lambdas/functions/control-plane/src/scale-runners/job-retry.ts @@ -5,7 +5,7 @@ import type { ActionRequestMessage, ActionRequestMessageRetry } from './types'; import { getOctokit } from '../github/octokit'; import { MetricUnit } from '@aws-lambda-powertools/metrics'; import yn from 'yn'; -import { multiOrgEnabled } from '../github/multi-org'; +import { multiOrgEnabled, normalizeOrganization } from '../github/multi-org'; interface JobRetryConfig { enable: boolean; @@ -39,6 +39,7 @@ export async function publishRetryMessage(payload: ActionRequestMessage): Promis } export async function checkAndRetryJob(payload: ActionRequestMessageRetry): Promise { + if (multiOrgEnabled()) payload = { ...payload, repositoryOwner: normalizeOrganization(payload.repositoryOwner) }; const enableOrgLevel = multiOrgEnabled() || yn(process.env.ENABLE_ORGANIZATION_RUNNERS, { default: true }); const runnerType = enableOrgLevel ? 'Org' : 'Repo'; const runnerOwner = enableOrgLevel ? payload.repositoryOwner : `${payload.repositoryOwner}/${payload.repositoryName}`; diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts index 48102b1011..23c4383c85 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts @@ -277,10 +277,31 @@ describe('Scale down runners', () => { expect(mockOctokit.apps.getOrgInstallation).toHaveBeenCalledWith({ org: 'org-b' }); }); + it('shares idle retention and GitHub lookups across mixed-case owner tags', async () => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + process.env.SCALE_DOWN_CONFIG = JSON.stringify([{ idleCount: 1, cron: '* * * * * *', timeZone: 'UTC' }]); + const runners = ['Org-A', 'org-a'].map((org, i) => + createRunnerTestData(String(i), 'Org', 60, true, false, false, org), + ); + mockProviderRunners(runners); + mockGitHubRunners(runners); + await scaleDown(); + expect(mockTerminateRunners).toHaveBeenCalledTimes(1); + expect(mockOctokit.apps.getOrgInstallation).toHaveBeenCalledExactlyOnceWith({ org: 'org-a' }); + expect(mockOctokit.paginate).toHaveBeenCalledExactlyOnceWith(mockOctokit.actions.listSelfHostedRunnersForOrg, { + org: 'org-a', + per_page: 100, + }); + expect(mockOctokit.actions.deleteSelfHostedRunnerFromOrg).toHaveBeenCalledWith( + expect.objectContaining({ org: 'org-a' }), + ); + expect(runners[0].owner).toBe('Org-A'); + }); + it('checks tagged orphans against their owning organization even when runner IDs overlap', async () => { process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; vi.mocked(ghAuth.getStoredInstallationId).mockResolvedValueOnce(999); - const runners = ['org-a', 'org-b'].map((org) => createRunnerTestData(org, 'Org', 60, true, true, false, org, 42)); + const runners = ['Org-A', 'ORG-B'].map((org) => createRunnerTestData(org, 'Org', 60, true, true, false, org, 42)); mockProviderRunners(runners); mockOctokit.actions.getSelfHostedRunnerForOrg.mockImplementation(async ({ org }) => { if (org === 'org-a') diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts index a35d2ec972..8e2bdb13db 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts @@ -5,7 +5,7 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; import moment from 'moment'; -import { multiOrgEnabled } from '../github/multi-org'; +import { multiOrgEnabled, normalizeOrganization } from '../github/multi-org'; import { createGithubAppAuth, createGithubInstallationAuth, @@ -373,7 +373,7 @@ async function lastChanceCheckOrphanRunner(runner: RunnerInfo): Promise async function terminateOrphan(environment: string, computeProvider: ScaleDownComputeProvider): Promise { try { - const orphanRunners = await computeProvider.list(environment, true); + const orphanRunners = (await computeProvider.list(environment, true)).map(normalizeRunnerOwner); for (const runner of orphanRunners) { if (runner.bypassRemoval) { @@ -412,7 +412,13 @@ export function newestFirstStrategy(a: RunnerInfo, b: RunnerInfo): number { } async function listRunners(environment: string, computeProvider: ScaleDownComputeProvider) { - return await computeProvider.list(environment); + return (await computeProvider.list(environment)).map(normalizeRunnerOwner); +} + +function normalizeRunnerOwner(runner: RunnerInfo): RunnerInfo { + return multiOrgEnabled() && runner.type === 'Org' && runner.owner + ? { ...runner, owner: normalizeOrganization(runner.owner) } + : runner; } function filterRunners(runners: RunnerInfo[]): RunnerInfo[] { diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts index 70162edc7b..4580829d55 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts @@ -106,8 +106,16 @@ it('keeps mixed-org batches and maximum counts separate when multi-org is enable installationId: i + 10, })); expect(await scaleUp(messages)).toEqual([]); - expect(provider.getCurrentRunners).toHaveBeenCalledWith(state, { runnerType: 'Org', runnerOwner: 'org-a' }); - expect(provider.getCurrentRunners).toHaveBeenCalledWith(state, { runnerType: 'Org', runnerOwner: 'org-b' }); + expect(provider.getCurrentRunners).toHaveBeenCalledWith(state, { + runnerType: 'Org', + runnerOwner: 'org-a', + runnerOwnerIgnoreCase: true, + }); + expect(provider.getCurrentRunners).toHaveBeenCalledWith(state, { + runnerType: 'Org', + runnerOwner: 'org-b', + runnerOwnerIgnoreCase: true, + }); expect(provider.createRunners).toHaveBeenCalledTimes(1); expect(provider.createRunners).toHaveBeenCalledWith( expect.objectContaining({ @@ -116,3 +124,29 @@ it('keeps mixed-org batches and maximum counts separate when multi-org is enable }), ); }); + +it('groups case variants into one org capacity check and registration batch', async () => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + process.env.RUNNERS_MAXIMUM_COUNT = '2'; + const { provider, state } = computeProviders[0]; + mockedResolveCapability.mockReturnValue(() => provider); + provider.resolveLabelsForRunners.mockResolvedValue({ state, runnerLabels: [] }); + provider.getCurrentRunners.mockResolvedValue(1); + provider.createRunners.mockResolvedValue({ + instances: ['runner-a'], + retryableErrorCount: 0, + nonRetryableErrorCount: 0, + }); + await scaleUp(['Org-A', 'org-a'].map((org) => ({ ...payloads[0], repositoryOwner: org, messageId: org }))); + expect(provider.getCurrentRunners).toHaveBeenCalledExactlyOnceWith(state, { + runnerType: 'Org', + runnerOwner: 'org-a', + runnerOwnerIgnoreCase: true, + }); + expect(provider.createRunners).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + numberOfRunners: 1, + githubRunnerConfig: expect.objectContaining({ runnerOwner: 'org-a' }), + }), + ); +}); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts index 1645ca5922..b3c84b74f0 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -5,7 +5,7 @@ import { createStorageProviders, type StorageProviders } from '@aws-github-runne import { Octokit } from '@octokit/rest'; import yn from 'yn'; -import { multiOrgEnabled } from '../github/multi-org'; +import { multiOrgEnabled, normalizeOrganization } from '../github/multi-org'; import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; import { controlPlaneProviderRegistry } from '../control-plane-providers'; import { @@ -135,7 +135,10 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise(); const retryMessageIds = new Set(); - for (const payload of payloads) { + for (const originalPayload of payloads) { + const payload = multiOrgEnabled() + ? { ...originalPayload, repositoryOwner: normalizeOrganization(originalPayload.repositoryOwner) } + : originalPayload; const { eventType, messageId, repositoryName, repositoryOwner, labels } = payload; if (ephemeralEnabled && eventType !== 'workflow_job') { logger.warn( @@ -290,7 +293,11 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise { vi.clearAllMocks(); }); - it('lists only running instances managed for the requested pool', async () => { + it.each([false, true])('lists running pool instances with case-insensitive ownership=%s', async (ignoreCase) => { + const ownership = ignoreCase ? { runnerOwnerIgnoreCase: true } : {}; const runners: RunnerInfo[] = [{ id: 'i-running', owner: 'owner', type: 'Org' }]; ec2Operations.list.mockResolvedValue(runners); await expect( capability.listRunners({ + ...ownership, environment: 'test-environment', runnerOwner: 'owner', runnerType: 'Org', }), ).resolves.toBe(runners); expect(ec2Operations.list).toHaveBeenCalledWith({ + ...ownership, environment: 'test-environment', runnerOwner: 'owner', runnerType: 'Org', diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.ts index 43ec0aacf9..a1ebea4c07 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.ts @@ -36,11 +36,9 @@ export function createEc2PoolCapability( createStartRunnerConfig: CreateStartRunnerConfig, ): Omit, 'type'> { return { - listRunners: ({ environment, runnerOwner, runnerType }) => + listRunners: (filters) => ec2Operations.list({ - environment, - runnerOwner, - runnerType, + ...filters, statuses: ['running'], }), countAvailableRunners: countAvailableEc2PoolRunners, diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts index 2a79eea40d..0d797ba622 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts @@ -1329,3 +1329,19 @@ describe('parseEc2OverrideConfig', () => { }); }); }); + +it('forwards case-insensitive owner matching for capacity counts', async () => { + mockListRunners.mockResolvedValue([]); + const resolution = await capability.resolveLabelsForRunners([]); + await capability.getCurrentRunners(resolution.state, { + runnerType: 'Org', + runnerOwner: 'org-a', + runnerOwnerIgnoreCase: true, + }); + expect(mockListRunners).toHaveBeenCalledWith({ + environment: 'unit-test-environment', + runnerType: 'Org', + runnerOwner: 'org-a', + runnerOwnerIgnoreCase: true, + }); +}); diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts index a27758b574..65e16f3fb6 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts @@ -58,8 +58,8 @@ export function createEc2ScaleUpCapability( ): Omit, 'type'> { return { resolveLabelsForRunners: (labels) => resolveEc2ScaleUpRunnerLabels(ec2Operations, labels), - getCurrentRunners: async (_state, { runnerType, runnerOwner }) => - (await ec2Operations.list({ environment: process.env.ENVIRONMENT, runnerType, runnerOwner })).length, + getCurrentRunners: async (_state, filters) => + (await ec2Operations.list({ environment: process.env.ENVIRONMENT, ...filters })).length, createRunners: async ({ githubRunnerConfig, numberOfRunners, githubInstallationClient, state, storage }) => { const config = loadEc2ScaleUpProviderConfig(); diff --git a/lambdas/libs/compute-providers/aws/ec2/src/runners.test.ts b/lambdas/libs/compute-providers/aws/ec2/src/runners.test.ts index 9e5c116ef4..f5b21eb81a 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/runners.test.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/runners.test.ts @@ -86,6 +86,33 @@ describe('list instances', () => { vi.clearAllMocks(); }); + it('counts existing mixed-case owner tags across pages without including another organization', async () => { + mockEC2Client.reset(); + const page = (owner: string, id: string) => ({ + InstanceId: id, + Tags: [ + { Key: 'ghr:Owner', Value: owner }, + { Key: 'ghr:Type', Value: 'Org' }, + ], + }); + mockEC2Client + .on(DescribeInstancesCommand) + .resolvesOnce({ Reservations: [{ Instances: [page('Org-A', 'one')] }], NextToken: 'next' }) + .resolvesOnce({ Reservations: [{ Instances: [page('org-a', 'two'), page('org-b', 'other')] }] }); + const runners = await ec2Operations.list({ + environment: ENVIRONMENT, + runnerType: 'Org', + runnerOwner: 'org-a', + runnerOwnerIgnoreCase: true, + }); + expect(runners.map((runner) => runner.id)).toEqual(['one', 'two']); + for (const call of mockEC2Client.commandCalls(DescribeInstancesCommand)) { + expect(call.args[0].input.Filters).toContainEqual({ Name: 'tag:ghr:environment', Values: [ENVIRONMENT] }); + expect(call.args[0].input.Filters).toContainEqual({ Name: 'tag:ghr:Type', Values: ['Org'] }); + expect(call.args[0].input.Filters?.some((filter) => filter.Name === 'tag:ghr:Owner')).toBe(false); + } + }); + it('returns a list of instances (Non JIT)', async () => { mockEC2Client.on(DescribeInstancesCommand).resolves(mockRunningInstances); const resp = await ec2Operations.list(); diff --git a/lambdas/libs/compute-providers/aws/ec2/src/runners.ts b/lambdas/libs/compute-providers/aws/ec2/src/runners.ts index 6269790adb..8be12ca075 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/runners.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/runners.ts @@ -91,7 +91,10 @@ async function listEc2Runners( for (const filter of ec2Filters) { runners.push(...(await getRunners(ec2Client, filter, signal))); } - return runners; + // EC2 tag filters are case-sensitive. In opt-in mode retain the environment/type + // filters in AWS, then match ownership locally so pre-existing mixed-case tags count too. + const owner = filters?.runnerOwnerIgnoreCase ? filters.runnerOwner?.toLowerCase() : undefined; + return owner ? runners.filter((runner) => runner.owner?.toLowerCase() === owner) : runners; } function constructFilters(filters?: Ec2ListRunnerFilters): Ec2Filter[][] { @@ -104,7 +107,9 @@ function constructFilters(filters?: Ec2ListRunnerFilters): Ec2Filter[][] { } if (filters.runnerType && filters.runnerOwner) { ec2FiltersBase.push({ Name: `tag:ghr:Type`, Values: [filters.runnerType] }); - ec2FiltersBase.push({ Name: `tag:ghr:Owner`, Values: [filters.runnerOwner] }); + if (!filters.runnerOwnerIgnoreCase) { + ec2FiltersBase.push({ Name: `tag:ghr:Owner`, Values: [filters.runnerOwner] }); + } } if (filters.orphan) { ec2FiltersBase.push({ Name: 'tag:ghr:orphan', Values: ['true'] }); diff --git a/lambdas/libs/compute-providers/core/index.ts b/lambdas/libs/compute-providers/core/index.ts index 2457719a42..d8e3d13063 100644 --- a/lambdas/libs/compute-providers/core/index.ts +++ b/lambdas/libs/compute-providers/core/index.ts @@ -43,6 +43,8 @@ export type CreateStartRunnerConfig = ( ) => Promise; export interface CurrentRunnersInput { + /** Match existing owner metadata without regard to case. Defaults to exact matching. */ + runnerOwnerIgnoreCase?: boolean; runnerType: RunnerType; runnerOwner: string; } @@ -98,6 +100,8 @@ export interface RunnerInfo { } export interface ListRunnerFilters { + /** Match existing owner metadata without regard to case. Defaults to exact matching. */ + runnerOwnerIgnoreCase?: boolean; runnerType?: RunnerType; runnerOwner?: string; environment?: string; @@ -126,6 +130,8 @@ export interface RunnerStatus { } export interface ListPoolRunnersInput { + /** Match existing owner metadata without regard to case. Defaults to exact matching. */ + runnerOwnerIgnoreCase?: boolean; environment: string; runnerOwner: string; runnerType: RunnerType; From aaf4b2d03774e7e89aaa86eda50719f17c50070e Mon Sep 17 00:00:00 2001 From: Guilherme Caulada Date: Mon, 14 Sep 2026 11:19:47 -0300 Subject: [PATCH 4/4] fix: share org idle allowance with legacy repository runners --- docs/multi-org.md | 2 +- .../src/scale-runners/scale-down.test.ts | 49 +++++++++++++++++++ .../src/scale-runners/scale-down.ts | 20 ++++++-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/docs/multi-org.md b/docs/multi-org.md index 03eef22cb0..ae92bf4864 100644 --- a/docs/multi-org.md +++ b/docs/multi-org.md @@ -40,6 +40,6 @@ For the legacy `modules/multi-runner` interface, set `enable_multi_org_runners` - 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. 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. +- 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/scale-runners/scale-down.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts index 23c4383c85..9679d44c1a 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts @@ -298,6 +298,55 @@ describe('Scale down runners', () => { expect(runners[0].owner).toBe('Org-A'); }); + it.each(['oldest_first', 'newest_first'])( + 'shares one organization allowance with legacy repository runners using %s eviction', + async (evictionStrategy) => { + process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; + process.env.SCALE_DOWN_CONFIG = JSON.stringify([ + { idleCount: 1, cron: '* * * * * *', timeZone: 'UTC', evictionStrategy }, + ]); + const orgRunner = createRunnerTestData('new-org', 'Org', 40, true, false, false, 'acme'); + const repoA = createRunnerTestData('old-a', 'Repo', 60, true, false, false, 'ACME/repo-a'); + const repoB = createRunnerTestData('old-b', 'Repo', 90, true, false, false, 'Acme/repo-b'); + const otherOrg = createRunnerTestData('other', 'Org', 50, true, false, false, 'other'); + // Interleave owners so retention cannot depend on traversal order. + const runners = [repoA, otherOrg, orgRunner, repoB]; + mockProviderRunners(runners); + mockOctokit.paginate.mockImplementation(async (_route, { org, owner, repo }) => + runners + .filter((runner) => runner.owner === (org ?? `${owner}/${repo}`)) + .map((runner) => ({ id: runner.id, name: runner.id })), + ); + await scaleDown(); + expect(mockTerminateRunners).toHaveBeenCalledTimes(2); + expect(mockTerminateRunners).toHaveBeenCalledWith(repoA.id); + expect(mockTerminateRunners).not.toHaveBeenCalledWith(otherOrg.id); + const retained = evictionStrategy === 'oldest_first' ? orgRunner : repoB; + const removed = evictionStrategy === 'oldest_first' ? repoB : orgRunner; + expect(mockTerminateRunners).not.toHaveBeenCalledWith(retained.id); + expect(mockTerminateRunners).toHaveBeenCalledWith(removed.id); + expect(mockOctokit.actions.deleteSelfHostedRunnerFromRepo).toHaveBeenCalledWith({ + owner: 'ACME', + repo: 'repo-a', + runner_id: repoA.id, + }); + if (evictionStrategy === 'newest_first') { + expect(mockOctokit.actions.deleteSelfHostedRunnerFromOrg).toHaveBeenCalledWith({ + org: 'acme', + runner_id: orgRunner.id, + }); + } else { + expect(mockOctokit.actions.deleteSelfHostedRunnerFromRepo).toHaveBeenCalledWith({ + owner: 'Acme', + repo: 'repo-b', + runner_id: repoB.id, + }); + } + expect(repoA.owner).toBe('ACME/repo-a'); + expect(repoA.type).toBe('Repo'); + }, + ); + it('checks tagged orphans against their owning organization even when runner IDs overlap', async () => { process.env.ENABLE_MULTI_ORG_RUNNERS = 'true'; vi.mocked(ghAuth.getStoredInstallationId).mockResolvedValueOnce(999); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts index 8e2bdb13db..853ea000ff 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts @@ -280,6 +280,12 @@ async function removeRunner( } } +function idleRetentionOwner(runner: RunnerInfo): string { + // Legacy Repo runners share their organization's allowance after enabling multi-org. + // Keep the original owner and type on the runner for installation lookup and removal. + return multiOrgEnabled() ? normalizeOrganization(runner.owner.split('/')[0]) : runner.owner; +} + async function evaluateAndRemoveRunners( runners: RunnerInfo[], scaleDownConfigs: ScalingDownConfigList, @@ -287,17 +293,21 @@ async function evaluateAndRemoveRunners( ): Promise { let idleCounter = getIdleRunnerCount(scaleDownConfigs); const evictionStrategy = getEvictionStrategy(scaleDownConfigs); - const ownerTags = new Set(runners.map((runner) => runner.owner)); + const retentionOwners = new Set(runners.map(idleRetentionOwner)); - for (const ownerTag of ownerTags) { + for (const retentionOwner of retentionOwners) { if (multiOrgEnabled()) { idleCounter = getIdleRunnerCount(scaleDownConfigs); } const ownerRunners = runners - .filter((runner) => runner.owner === ownerTag) + .filter((runner) => idleRetentionOwner(runner) === retentionOwner) .sort(evictionStrategy === 'oldest_first' ? oldestFirstStrategy : newestFirstStrategy); - logger.debug(`Found: '${ownerRunners.length}' active GitHub runners with owner tag: '${ownerTag}'`); - logger.debug(`Active GitHub runners with owner tag: '${ownerTag}': ${JSON.stringify(ownerRunners)}`); + logger.debug( + `Found: '${ownerRunners.length}' active GitHub runners with idle retention owner: '${retentionOwner}'`, + ); + logger.debug( + `Active GitHub runners with idle retention owner: '${retentionOwner}': ${JSON.stringify(ownerRunners)}`, + ); for (const runner of ownerRunners) { if (runner.bypassRemoval) { logger.debug(`Runner '${runner.id}' has bypass-removal tag set, skipping evaluation.`);