diff --git a/docs/rate-limits-and-tuning.md b/docs/rate-limits-and-tuning.md index 2bed61c9ed..571fd96ff1 100644 --- a/docs/rate-limits-and-tuning.md +++ b/docs/rate-limits-and-tuning.md @@ -50,6 +50,17 @@ Sustained: 10,000/hour ÷ 60 = **~166 runners/minute**. Without a token cache, each runner also costs a `POST /app/installations/{id}/access_tokens` (5 points) against the `core` endpoint. This doesn't directly reduce JIT throughput (different endpoint) but competes with `isJobQueued` for the `core` hourly budget. +### Distributing load across multiple GitHub Apps + +Rate limits are per App installation and cannot be raised. To scale beyond one App's budget, configure extra Apps with `additional_github_apps`. The control-plane lambdas select one App per invocation, making the effective limit N × the per-App limit. + +> [!IMPORTANT] +> Every additional App must be installed on the same organizations or repositories as the primary App. The module cannot verify this. A missing installation surfaces at runtime as installation lookup 404s on the fraction of invocations that select the misconfigured App, which is hard to trace back to the installation. + +Only the primary App needs a webhook configured in GitHub; additional Apps are used for API calls only. Set `installation_id` per additional App to skip one installation lookup per invocation. + +The lambdas receive additional App credentials through a manifest SSM parameter that lists the per-App credential parameter names, so the lambda environment size stays constant regardless of App count. + ### GHES Rate limits are **disabled by default** on GitHub Enterprise Server and must be explicitly enabled by the site admin. When enabled, the same formula applies. diff --git a/lambdas/functions/control-plane/src/github/auth.test.ts b/lambdas/functions/control-plane/src/github/auth.test.ts index dd2cf3b8c2..c2524503b7 100644 --- a/lambdas/functions/control-plane/src/github/auth.test.ts +++ b/lambdas/functions/control-plane/src/github/auth.test.ts @@ -2,7 +2,7 @@ import { createAppAuth } from '@octokit/auth-app'; import { StrategyOptions } from '@octokit/auth-app/dist-types/types'; import { request } from '@octokit/request'; import { RequestInterface, RequestParameters } from '@octokit/types'; -import { getParameters } from '@aws-github-runner/aws-ssm-util'; +import { getParameter, getParameters } from '@aws-github-runner/aws-ssm-util'; import { generateKeyPairSync } from 'node:crypto'; import * as nock from 'nock'; @@ -35,6 +35,7 @@ const PARAMETER_GITHUB_APP_ID_NAME = `/actions-runner/${ENVIRONMENT}/github_app_ const PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`; const mockedGetParameters = vi.mocked(getParameters); +const mockedGetParameter = vi.mocked(getParameter); beforeEach(() => { vi.resetModules(); @@ -341,23 +342,32 @@ describe('Test getStoredInstallationId', () => { vi.mocked(createAppAuth).mockReturnValue(mockWithHook); }); - it('returns stored installation ID when configured', async () => { - const installationIdParam = `/actions-runner/${ENVIRONMENT}/github_app_installation_id`; - process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = installationIdParam; + it('returns stored installation ID when configured for an additional app', async () => { + const appIdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_id`; + const appKeyParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_key_base64`; + const installationIdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_installation_id`; + process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME = `/actions-runner/${ENVIRONMENT}/additional_github_apps_manifest`; + mockedGetParameter.mockResolvedValueOnce( + JSON.stringify([ + { idParamName: appIdParam, keyParamName: appKeyParam, installationIdParamName: installationIdParam }, + ]), + ); mockedGetParameters.mockResolvedValueOnce( new Map([ [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], + [appIdParam, '2'], + [appKeyParam, b64], [installationIdParam, '12345'], ]), ); - const result = await getStoredInstallationId(0); + const result = await getStoredInstallationId(1); expect(result).toBe(12345); }); - it('returns undefined when installation ID param is empty', async () => { - process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = ''; + it('returns undefined when the manifest env var is empty', async () => { + process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME = ''; mockedGetParameters.mockResolvedValueOnce( new Map([ [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], @@ -369,8 +379,8 @@ describe('Test getStoredInstallationId', () => { expect(result).toBeUndefined(); }); - it('returns undefined when env var is not set', async () => { - delete process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME; + it('returns undefined when the manifest env var is not set', async () => { + delete process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME; mockedGetParameters.mockResolvedValueOnce( new Map([ [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], @@ -383,7 +393,7 @@ describe('Test getStoredInstallationId', () => { }); it('returns undefined for out-of-bounds appIndex', async () => { - process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = ''; + delete process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME; mockedGetParameters.mockResolvedValueOnce( new Map([ [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID], @@ -395,21 +405,21 @@ describe('Test getStoredInstallationId', () => { expect(result).toBeUndefined(); }); - it('loads installation IDs for multi-app setup', async () => { - const app1IdParam = `/actions-runner/${ENVIRONMENT}/github_app_id`; + it('loads installation IDs for multi-app setup from the manifest', async () => { const app2IdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_id`; - const app1KeyParam = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`; const app2KeyParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_key_base64`; const app2InstallParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_installation_id`; - process.env.PARAMETER_GITHUB_APP_ID_NAME = `${app1IdParam}:${app2IdParam}`; - process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `${app1KeyParam}:${app2KeyParam}`; - process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = `:${app2InstallParam}`; - + process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME = `/actions-runner/${ENVIRONMENT}/additional_github_apps_manifest`; + mockedGetParameter.mockResolvedValueOnce( + JSON.stringify([ + { idParamName: app2IdParam, keyParamName: app2KeyParam, installationIdParamName: app2InstallParam }, + ]), + ); mockedGetParameters.mockResolvedValueOnce( new Map([ - [app1IdParam, '1'], - [app1KeyParam, b64], + [PARAMETER_GITHUB_APP_ID_NAME, '1'], + [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64], [app2IdParam, '2'], [app2KeyParam, b64], [app2InstallParam, '67890'], diff --git a/lambdas/functions/control-plane/src/modules.d.ts b/lambdas/functions/control-plane/src/modules.d.ts index 84b0d23a02..f389817bfd 100644 --- a/lambdas/functions/control-plane/src/modules.d.ts +++ b/lambdas/functions/control-plane/src/modules.d.ts @@ -15,6 +15,7 @@ declare namespace NodeJS { PARAMETER_GITHUB_APP_CLIENT_SECRET_NAME: string; PARAMETER_GITHUB_APP_ID_NAME: string; PARAMETER_GITHUB_APP_KEY_BASE64_NAME: string; + PARAMETER_GITHUB_APPS_MANIFEST_NAME?: string; RUNNER_OWNER: string; COMPUTE_PROVIDER_TYPE?: string; SCALE_DOWN_CONFIG: string; diff --git a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts index fe29bfc244..2604ce057e 100644 --- a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts +++ b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts @@ -9,6 +9,7 @@ declare global { PARAMETER_GITHUB_APP_ID_NAME?: string; PARAMETER_GITHUB_APP_KEY_BASE64_NAME?: string; PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME?: string; + PARAMETER_GITHUB_APPS_MANIFEST_NAME?: string; } } } diff --git a/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.test.ts b/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.test.ts index 19b19a1cbd..6f1685e8a0 100644 --- a/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.test.ts +++ b/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getParameters } from '@aws-github-runner/aws-ssm-util'; +import { getParameter, getParameters } from '@aws-github-runner/aws-ssm-util'; import { createAwsSsmGitHubAppCredentialsStore } from './github-app-credentials-store'; vi.mock('@aws-github-runner/aws-ssm-util', () => ({ + getParameter: vi.fn(), getParameters: vi.fn(), })); @@ -28,7 +29,7 @@ describe('aws_ssm GitHub App credentials store', () => { vi.clearAllMocks(); process.env.PARAMETER_GITHUB_APP_ID_NAME = 'app-id'; process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = 'app-key'; - delete process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME; + delete process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME; }); it('loads batched credentials and decodes escaped newlines', async () => { @@ -47,9 +48,12 @@ describe('aws_ssm GitHub App credentials store', () => { }); it('loads per-app installation IDs in the same order as app IDs', async () => { - process.env.PARAMETER_GITHUB_APP_ID_NAME = 'id-0:id-1'; - process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = 'key-0:key-1'; - process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = ':installation-1'; + process.env.PARAMETER_GITHUB_APP_ID_NAME = 'id-0'; + process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = 'key-0'; + process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME = 'manifest'; + vi.mocked(getParameter).mockResolvedValue( + JSON.stringify([{ idParamName: 'id-1', keyParamName: 'key-1', installationIdParamName: 'installation-1' }]), + ); getParametersMock.mockResolvedValue( new Map([ ['id-0', '123'], @@ -71,9 +75,11 @@ describe('aws_ssm GitHub App credentials store', () => { expect(() => createAwsSsmGitHubAppCredentialsStore()).toThrow(`Environment variable ${name} is not set`); }); - it('rejects mismatched app and key parameter lists', () => { - process.env.PARAMETER_GITHUB_APP_ID_NAME = 'id-0:id-1'; - expect(() => createAwsSsmGitHubAppCredentialsStore()).toThrow('parameter count mismatch'); + it('rejects malformed manifest JSON', async () => { + process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME = 'manifest'; + vi.mocked(getParameter).mockResolvedValue('invalid-json'); + await expect(createAwsSsmGitHubAppCredentialsStore().get()).rejects.toThrow(); + expect(getParametersMock).not.toHaveBeenCalled(); }); it('logs safe context when a credential parameter is missing', async () => { diff --git a/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.ts b/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.ts index 803020ba4c..089f654a09 100644 --- a/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.ts +++ b/lambdas/libs/storage-providers/aws/ssm/github-app-credentials-store.ts @@ -1,4 +1,4 @@ -import { getParameters } from '@aws-github-runner/aws-ssm-util'; +import { getParameter, getParameters } from '@aws-github-runner/aws-ssm-util'; import type { GitHubAppCredential, GitHubAppCredentialsStore } from '../../core'; import { createAwsSsmStorageLogger, getErrorNames } from './logger'; @@ -8,42 +8,55 @@ const logger = createAwsSsmStorageLogger('github-app-credentials-store'); interface AwsSsmGitHubAppCredentialsEnvironment { PARAMETER_GITHUB_APP_ID_NAME?: string; PARAMETER_GITHUB_APP_KEY_BASE64_NAME?: string; - PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME?: string; + PARAMETER_GITHUB_APPS_MANIFEST_NAME?: string; } export function createAwsSsmGitHubAppCredentialsStore( environment: Readonly = process.env, ): GitHubAppCredentialsStore { - const idParameters = splitParameterNames(environment.PARAMETER_GITHUB_APP_ID_NAME, 'PARAMETER_GITHUB_APP_ID_NAME'); - const keyParameters = splitParameterNames( + const idParameter = requireParameterName(environment.PARAMETER_GITHUB_APP_ID_NAME, 'PARAMETER_GITHUB_APP_ID_NAME'); + const keyParameter = requireParameterName( environment.PARAMETER_GITHUB_APP_KEY_BASE64_NAME, 'PARAMETER_GITHUB_APP_KEY_BASE64_NAME', ); - const installationIdParameters = environment.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME?.split(':') ?? []; - - if (idParameters.length !== keyParameters.length) { - throw new Error(`GitHub App parameter count mismatch: ${idParameters.length} IDs vs ${keyParameters.length} keys`); - } + return new AwsSsmGitHubAppCredentialsStore( + idParameter, + keyParameter, + environment.PARAMETER_GITHUB_APPS_MANIFEST_NAME, + ); +} - return new AwsSsmGitHubAppCredentialsStore(idParameters, keyParameters, installationIdParameters); +interface AdditionalAppManifestEntry { + idParamName: string; + keyParamName: string; + installationIdParamName?: string | null; } class AwsSsmGitHubAppCredentialsStore implements GitHubAppCredentialsStore { constructor( - private readonly idParameters: string[], - private readonly keyParameters: string[], - private readonly installationIdParameters: string[], + private readonly idParameter: string, + private readonly keyParameter: string, + private readonly manifestParameter?: string, ) {} async get(): Promise { + const entries: AdditionalAppManifestEntry[] = [{ idParamName: this.idParameter, keyParamName: this.keyParameter }]; + if (this.manifestParameter) { + const manifest = JSON.parse(await getParameter(this.manifestParameter)) as AdditionalAppManifestEntry[]; + entries.push(...manifest); + } + const idParameters = entries.map((entry) => entry.idParamName); + const keyParameters = entries.map((entry) => entry.keyParamName); + const installationIdParameters = entries.map((entry) => entry.installationIdParamName); + const parameterNames = [ - ...this.idParameters, - ...this.keyParameters, - ...this.installationIdParameters.filter(Boolean), + ...idParameters, + ...keyParameters, + ...installationIdParameters.filter((name): name is string => Boolean(name)), ]; logger.debug('Reading GitHub App credential parameters', { parameterCount: parameterNames.length, - appCount: this.idParameters.length, + appCount: idParameters.length, }); let parameters: Map; @@ -52,13 +65,13 @@ class AwsSsmGitHubAppCredentialsStore implements GitHubAppCredentialsStore { } catch (error) { logger.error('Failed to read GitHub App credential parameters', { parameterCount: parameterNames.length, - appCount: this.idParameters.length, + appCount: idParameters.length, errorNames: getErrorNames(error), }); throw error; } - const credentials = this.idParameters.map((idParameter, index) => { + const credentials = idParameters.map((idParameter, index) => { const appIdValue = parameters.get(idParameter); if (!appIdValue) { logger.error('GitHub App credential parameter is missing', { @@ -68,7 +81,7 @@ class AwsSsmGitHubAppCredentialsStore implements GitHubAppCredentialsStore { }); throw new Error(`Parameter ${idParameter} not found`); } - const keyParameter = this.keyParameters[index]; + const keyParameter = keyParameters[index]; const privateKeyBase64 = parameters.get(keyParameter); if (!privateKeyBase64) { logger.error('GitHub App credential parameter is missing', { @@ -78,7 +91,7 @@ class AwsSsmGitHubAppCredentialsStore implements GitHubAppCredentialsStore { }); throw new Error(`Parameter ${keyParameter} not found`); } - const installationIdParameter = this.installationIdParameters[index]; + const installationIdParameter = installationIdParameters[index]; const installationIdValue = installationIdParameter ? parameters.get(installationIdParameter) : undefined; return { appId: Number.parseInt(appIdValue, 10), @@ -95,9 +108,9 @@ class AwsSsmGitHubAppCredentialsStore implements GitHubAppCredentialsStore { } } -function splitParameterNames(value: string | undefined, name: string): string[] { +function requireParameterName(value: string | undefined, name: string): string { if (!value || value.trim() === '') { throw new Error(`Environment variable ${name} is not set`); } - return value.split(':').filter(Boolean); + return value; } diff --git a/main.tf b/main.tf index cad9b66c58..7cb7c1026c 100644 --- a/main.tf +++ b/main.tf @@ -7,19 +7,18 @@ locals { primary_app_key_base64 = coalesce(var.github_app.key_base64_ssm, module.ssm.parameters.github_app_key_base64) github_app_parameters = { - id = concat( - [local.primary_app_id], - [for p in module.ssm.additional_app_parameters : p.id] - ) - key_base64 = concat( - [local.primary_app_key_base64], - [for p in module.ssm.additional_app_parameters : p.key_base64] - ) - installation_id = concat( - [null], - [for p in module.ssm.additional_app_parameters : p.installation_id] - ) + id = local.primary_app_id + key_base64 = local.primary_app_key_base64 webhook_secret = coalesce(var.github_app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) + # Additional apps flow to the lambdas through the manifest parameter so + # the lambda environment size stays constant regardless of app count. + additional_apps_manifest = module.ssm.additional_apps_manifest + additional_app_parameter_arns = flatten([ + for p in module.ssm.additional_app_parameters : concat( + [p.id.arn, p.key_base64.arn], + p.installation_id != null ? [p.installation_id.arn] : [] + ) + ]) } default_runner_labels = distinct(concat(["self-hosted", var.runner_os, var.runner_architecture])) @@ -406,8 +405,8 @@ locals { metrics = var.metrics enable_runner_deregistration = var.instance_termination_watcher.enable_runner_deregistration github_app_parameters = var.instance_termination_watcher.enable_runner_deregistration ? { - id = local.github_app_parameters.id[0] - key_base64 = local.github_app_parameters.key_base64[0] + id = local.github_app_parameters.id + key_base64 = local.github_app_parameters.key_base64 } : null ghes_url = var.ghes_url } diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index b9dbf243a3..23d51b7f65 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -7,19 +7,18 @@ locals { primary_app_key_base64 = coalesce(local.effective_config.github.app.key_base64_ssm, module.ssm.parameters.github_app_key_base64) github_app_parameters = { - id = concat( - [local.primary_app_id], - [for p in module.ssm.additional_app_parameters : p.id] - ) - key_base64 = concat( - [local.primary_app_key_base64], - [for p in module.ssm.additional_app_parameters : p.key_base64] - ) - installation_id = concat( - [null], - [for p in module.ssm.additional_app_parameters : p.installation_id] - ) + id = local.primary_app_id + key_base64 = local.primary_app_key_base64 webhook_secret = coalesce(local.effective_config.github.app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) + # Additional apps flow to the lambdas through the manifest parameter so + # the lambda environment size stays constant regardless of app count. + additional_apps_manifest = module.ssm.additional_apps_manifest + additional_app_parameter_arns = flatten([ + for p in module.ssm.additional_app_parameters : concat( + [p.id.arn, p.key_base64.arn], + p.installation_id != null ? [p.installation_id.arn] : [] + ) + ]) } ssm_root_path = trimsuffix(coalesce( diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 7c4a9807d1..b6d3a14d5d 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -45,25 +45,15 @@ output "webhook" { } output "ssm_parameters" { - value = merge( - { - id = { name = local.github_app_parameters.id[0].name, arn = local.github_app_parameters.id[0].arn } - key_base64 = { name = local.github_app_parameters.key_base64[0].name, arn = local.github_app_parameters.key_base64[0].arn } - webhook_secret = { name = local.github_app_parameters.webhook_secret.name, arn = local.github_app_parameters.webhook_secret.arn } - }, - { for idx, v in local.github_app_parameters.id : "github_app_id_${idx}" => { - name = v.name - arn = v.arn - } }, - { for idx, v in local.github_app_parameters.key_base64 : "github_app_key_base64_${idx}" => { - name = v.name - arn = v.arn - } }, - { "github_app_webhook_secret" = { - name = local.github_app_parameters.webhook_secret.name - arn = local.github_app_parameters.webhook_secret.arn - } }, - ) + value = { + id = { name = local.github_app_parameters.id.name, arn = local.github_app_parameters.id.arn } + key_base64 = { name = local.github_app_parameters.key_base64.name, arn = local.github_app_parameters.key_base64.arn } + webhook_secret = { name = local.github_app_parameters.webhook_secret.name, arn = local.github_app_parameters.webhook_secret.arn } + additional_apps_manifest = local.github_app_parameters.additional_apps_manifest != null ? { + name = local.github_app_parameters.additional_apps_manifest.name + arn = local.github_app_parameters.additional_apps_manifest.arn + } : null + } } output "instance_termination_watcher" { diff --git a/modules/multi-runner/termination-watcher.tf b/modules/multi-runner/termination-watcher.tf index a710ae9620..45e30373f7 100644 --- a/modules/multi-runner/termination-watcher.tf +++ b/modules/multi-runner/termination-watcher.tf @@ -35,8 +35,8 @@ locals { } enable_runner_deregistration = local.effective_config.compute_provider.aws.ec2.instance_termination_watcher.features.runner_deregistration.enabled github_app_parameters = local.effective_config.compute_provider.aws.ec2.instance_termination_watcher.features.runner_deregistration.enabled ? { - id = local.github_app_parameters.id[0] - key_base64 = local.github_app_parameters.key_base64[0] + id = local.github_app_parameters.id + key_base64 = local.github_app_parameters.key_base64 } : null ghes_url = local.effective_config.github.enterprise_server.url environment_variables = local.effective_config.compute_provider.aws.ec2.instance_termination_watcher.environment_variables diff --git a/modules/runners/README.md b/modules/runners/README.md index d228615edd..05c6ea5620 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -162,7 +162,7 @@ yarn run dist | [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | -| [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters.

Supports multiple GitHub Apps for random API rate limit distribution.
Each list element corresponds to one GitHub App and is a map containing
`name` and `arn` keys referencing SSM parameters. The first element is the
primary app (the one whose webhook secret is used for incoming webhook
validation). All apps must be installed on the same repositories/organizations.

The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly
select an app from the list for each GitHub API call, distributing rate
limit consumption across all configured apps. |
object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
| n/a | yes | +| [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters.

Supports multiple GitHub Apps for API rate limit distribution. `id` and
`key_base64` reference the primary app (the one whose webhook secret is
used for incoming webhook validation). Additional apps are delivered to
the lambdas via `additional_apps_manifest`, an SSM parameter whose value
lists the per-app credential parameter names, keeping the lambda
environment size constant regardless of app count.
`additional_app_parameter_arns` carries the ARNs of every additional app
credential parameter for the lambda IAM policies. All apps must be
installed on the same repositories/organizations as the primary app. |
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), [])
})
| n/a | yes | | [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 | | [idle\_config](#input\_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. |
list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
}))
| `[]` | no | | [instance\_allocation\_strategy](#input\_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`. | `string` | `"lowest-price"` | no | diff --git a/modules/runners/job-retry/README.md b/modules/runners/job-retry/README.md index 1eb06dab26..57c6d9dc91 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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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
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 d5455951d0..287d63d571 100644 --- a/modules/runners/job-retry/main.tf +++ b/modules/runners/job-retry/main.tf @@ -3,15 +3,15 @@ locals { name = "job-retry" 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_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 - JOB_QUEUE_SCALE_UP_URL = var.config.sqs_build_queue.url - PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github_app_parameters.id : p.name]) - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github_app_parameters.key_base64 : p.name]) - PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github_app_parameters.installation_id : p != null ? p.name : ""]) + ENABLE_ORGANIZATION_RUNNERS = var.config.enable_organization_runners + ENABLE_METRIC_JOB_RETRY = var.config.metrics.enable && var.config.metrics.metric.enable_job_retry + 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 + JOB_QUEUE_SCALE_UP_URL = var.config.sqs_build_queue.url + 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 : "" } config = merge(var.config, { @@ -67,9 +67,9 @@ resource "aws_iam_role_policy" "job_retry" { sqs_build_queue_arn = var.config.sqs_build_queue.arn sqs_job_retry_queue_arn = aws_sqs_queue.job_retry_check_queue.arn github_app_parameter_arns = jsonencode(concat( - [for p in var.config.github_app_parameters.id : p.arn], - [for p in var.config.github_app_parameters.key_base64 : p.arn], - [for p in var.config.github_app_parameters.installation_id : p.arn if p != null], + [var.config.github_app_parameters.id.arn, var.config.github_app_parameters.key_base64.arn], + var.config.github_app_parameters.additional_app_parameter_arns, + var.config.github_app_parameters.additional_apps_manifest != null ? [var.config.github_app_parameters.additional_apps_manifest.arn] : [], )) }) } diff --git a/modules/runners/job-retry/variables.tf b/modules/runners/job-retry/variables.tf index cb010d7552..1a2fff1dc1 100644 --- a/modules/runners/job-retry/variables.tf +++ b/modules/runners/job-retry/variables.tf @@ -44,9 +44,13 @@ variable "config" { ghes_url = optional(string, null) user_agent = optional(string, null) github_app_parameters = object({ - key_base64 = list(map(string)) - id = list(map(string)) - installation_id = list(object({ name = string, arn = string })) + 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) diff --git a/modules/runners/pool/README.md b/modules/runners/pool/README.md index c613bab02b..54b85d968e 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 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = 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_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({
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_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 f0d9ca1d29..f9e140317d 100644 --- a/modules/runners/pool/main.tf +++ b/modules/runners/pool/main.tf @@ -27,43 +27,43 @@ resource "aws_lambda_function" "pool" { environment { variables = { - AMI_ID_SSM_PARAMETER_NAME = var.config.ami_id_ssm_parameter_name - DISABLE_RUNNER_AUTOUPDATE = var.config.runner.disable_runner_autoupdate - ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral - ENABLE_JIT_CONFIG = var.config.runner.enable_jit_config - ENVIRONMENT = var.config.prefix - GHES_URL = var.config.ghes.url - USER_AGENT = var.config.user_agent - INSTANCE_ALLOCATION_STRATEGY = var.config.instance_allocation_strategy - INSTANCE_MAX_SPOT_PRICE = var.config.instance_max_spot_price - INSTANCE_TARGET_CAPACITY_TYPE = var.config.instance_target_capacity_type - INSTANCE_TYPE_PRIORITIES = var.config.instance_type_priorities != null ? jsonencode(var.config.instance_type_priorities) : "" - INSTANCE_TYPES = join(",", var.config.instance_types) - LAUNCH_TEMPLATE_NAME = var.config.runner.launch_template.name - LOG_LEVEL = upper(var.config.lambda.log_level) - NODE_TLS_REJECT_UNAUTHORIZED = var.config.ghes.url != null && !var.config.ghes.ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github_app_parameters.id : p.name]) - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github_app_parameters.key_base64 : p.name]) - PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github_app_parameters.installation_id : p != null ? p.name : ""]) - POWERTOOLS_LOGGER_LOG_EVENT = var.config.lambda.log_level == "debug" ? "true" : "false" - RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes - RUNNER_LABELS = lower(join(",", var.config.runner.labels)) - RUNNER_GROUP_NAME = var.config.runner.group_name - RUNNER_NAME_PREFIX = var.config.runner.name_prefix - RUNNER_OWNER = var.config.runner.pool_owner - RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count - SSM_TOKEN_PATH = var.config.ssm_token_path - SSM_CONFIG_PATH = var.config.ssm_config_path - SUBNET_IDS = join(",", var.config.subnet_ids) - POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool" - POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error - ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.config.runner.enable_on_demand_failover_for_errors) - SSM_PARAMETER_STORE_TAGS = var.config.lambda.parameter_store_tags - SCALE_ERRORS = jsonencode(var.config.runner.scale_errors) - USE_DEDICATED_HOST = var.config.runner.use_dedicated_host - INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners + AMI_ID_SSM_PARAMETER_NAME = var.config.ami_id_ssm_parameter_name + DISABLE_RUNNER_AUTOUPDATE = var.config.runner.disable_runner_autoupdate + ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral + ENABLE_JIT_CONFIG = var.config.runner.enable_jit_config + ENVIRONMENT = var.config.prefix + GHES_URL = var.config.ghes.url + USER_AGENT = var.config.user_agent + INSTANCE_ALLOCATION_STRATEGY = var.config.instance_allocation_strategy + INSTANCE_MAX_SPOT_PRICE = var.config.instance_max_spot_price + INSTANCE_TARGET_CAPACITY_TYPE = var.config.instance_target_capacity_type + INSTANCE_TYPE_PRIORITIES = var.config.instance_type_priorities != null ? jsonencode(var.config.instance_type_priorities) : "" + INSTANCE_TYPES = join(",", var.config.instance_types) + LAUNCH_TEMPLATE_NAME = var.config.runner.launch_template.name + LOG_LEVEL = upper(var.config.lambda.log_level) + NODE_TLS_REJECT_UNAUTHORIZED = var.config.ghes.url != null && !var.config.ghes.ssl_verify ? 0 : 1 + 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 : "" + POWERTOOLS_LOGGER_LOG_EVENT = var.config.lambda.log_level == "debug" ? "true" : "false" + RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes + RUNNER_LABELS = lower(join(",", var.config.runner.labels)) + RUNNER_GROUP_NAME = var.config.runner.group_name + RUNNER_NAME_PREFIX = var.config.runner.name_prefix + RUNNER_OWNER = var.config.runner.pool_owner + RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count + SSM_TOKEN_PATH = var.config.ssm_token_path + SSM_CONFIG_PATH = var.config.ssm_config_path + SUBNET_IDS = join(",", var.config.subnet_ids) + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool" + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.config.runner.enable_on_demand_failover_for_errors) + SSM_PARAMETER_STORE_TAGS = var.config.lambda.parameter_store_tags + SCALE_ERRORS = jsonencode(var.config.runner.scale_errors) + USE_DEDICATED_HOST = var.config.runner.use_dedicated_host + INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners } } @@ -106,9 +106,9 @@ resource "aws_iam_role_policy" "pool" { arn_ssm_parameters_path_config = var.config.arn_ssm_parameters_path_config arn_runner_instance_role = var.config.runner.role.arn github_app_parameter_arns = jsonencode(concat( - [for p in var.config.github_app_parameters.id : p.arn], - [for p in var.config.github_app_parameters.key_base64 : p.arn], - [for p in var.config.github_app_parameters.installation_id : p.arn if p != null], + [var.config.github_app_parameters.id.arn, var.config.github_app_parameters.key_base64.arn], + var.config.github_app_parameters.additional_app_parameter_arns, + var.config.github_app_parameters.additional_apps_manifest != null ? [var.config.github_app_parameters.additional_apps_manifest.arn] : [], )) kms_key_arn = var.config.kms_key_arn ami_kms_key_arn = var.config.ami_kms_key_arn diff --git a/modules/runners/pool/variables.tf b/modules/runners/pool/variables.tf index 4c577e86c7..4c3551c4c1 100644 --- a/modules/runners/pool/variables.tf +++ b/modules/runners/pool/variables.tf @@ -25,9 +25,13 @@ variable "config" { ssl_verify = string }) github_app_parameters = object({ - key_base64 = list(map(string)) - id = list(map(string)) - installation_id = list(object({ name = string, arn = string })) + 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({ diff --git a/modules/runners/scale-down.tf b/modules/runners/scale-down.tf index 1723824f35..1983cfea84 100644 --- a/modules/runners/scale-down.tf +++ b/modules/runners/scale-down.tf @@ -27,25 +27,25 @@ resource "aws_lambda_function" "scale_down" { environment { variables = { - ENVIRONMENT = var.prefix - 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 - LOG_LEVEL = upper(var.log_level) - MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner_os]) - NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.github_app_parameters.id : p.name]) - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.github_app_parameters.key_base64 : p.name]) - PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.github_app_parameters.installation_id : p != null ? p.name : ""]) - POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" - RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes - SCALE_DOWN_CONFIG = jsonencode(var.idle_config) - POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-down" - POWERTOOLS_METRICS_NAMESPACE = var.metrics.namespace - POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error - COMPUTE_PROVIDER_TYPE = "ec2" + ENVIRONMENT = var.prefix + 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 + LOG_LEVEL = upper(var.log_level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner_os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.github_app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github_app_parameters.key_base64.name + PARAMETER_GITHUB_APPS_MANIFEST_NAME = var.github_app_parameters.additional_apps_manifest != null ? var.github_app_parameters.additional_apps_manifest.name : "" + POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" + RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes + SCALE_DOWN_CONFIG = jsonencode(var.idle_config) + POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-down" + POWERTOOLS_METRICS_NAMESPACE = var.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + COMPUTE_PROVIDER_TYPE = "ec2" } } @@ -106,9 +106,9 @@ resource "aws_iam_role_policy" "scale_down" { policy = templatefile("${path.module}/policies/lambda-scale-down.json", { environment = var.prefix github_app_parameter_arns = jsonencode(concat( - [for p in var.github_app_parameters.id : p.arn], - [for p in var.github_app_parameters.key_base64 : p.arn], - [for p in var.github_app_parameters.installation_id : p.arn if p != null], + [var.github_app_parameters.id.arn, var.github_app_parameters.key_base64.arn], + var.github_app_parameters.additional_app_parameter_arns, + var.github_app_parameters.additional_apps_manifest != null ? [var.github_app_parameters.additional_apps_manifest.arn] : [], )) kms_key_arn = local.kms_key_arn }) diff --git a/modules/runners/scale-up.tf b/modules/runners/scale-up.tf index af52cd3ab7..d78eb7bfa5 100644 --- a/modules/runners/scale-up.tf +++ b/modules/runners/scale-up.tf @@ -30,47 +30,47 @@ resource "aws_lambda_function" "scale_up" { depends_on = [aws_cloudwatch_log_group.scale_up] environment { variables = { - AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name - DISABLE_RUNNER_AUTOUPDATE = var.disable_runner_autoupdate - ENABLE_EPHEMERAL_RUNNERS = var.enable_ephemeral_runners - ENABLE_JIT_CONFIG = var.enable_jit_config - ENABLE_JOB_QUEUED_CHECK = local.enable_job_queued_check - 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 - GHES_URL = var.ghes_url - USER_AGENT = var.user_agent - INSTANCE_ALLOCATION_STRATEGY = var.instance_allocation_strategy - INSTANCE_MAX_SPOT_PRICE = var.instance_max_spot_price - INSTANCE_TARGET_CAPACITY_TYPE = var.instance_target_capacity_type - INSTANCE_TYPE_PRIORITIES = var.instance_type_priorities != null ? jsonencode(var.instance_type_priorities) : "" - INSTANCE_TYPES = join(",", var.instance_types) - LAUNCH_TEMPLATE_NAME = aws_launch_template.runner.name - LOG_LEVEL = upper(var.log_level) - MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner_os]) - NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.github_app_parameters.id : p.name]) - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.github_app_parameters.key_base64 : p.name]) - PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.github_app_parameters.installation_id : p != null ? p.name : ""]) - POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" - POWERTOOLS_METRICS_NAMESPACE = var.metrics.namespace - POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error - RUNNER_LABELS = lower(join(",", var.runner_labels)) - RUNNER_GROUP_NAME = var.runner_group_name - RUNNER_NAME_PREFIX = var.runner_name_prefix - COMPUTE_PROVIDER_TYPE = "ec2" - RUNNERS_MAXIMUM_COUNT = var.runners_maximum_count - POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-up" - SSM_TOKEN_PATH = local.token_path - SSM_CONFIG_PATH = "${var.ssm_paths.root}/${var.ssm_paths.config}" - SSM_PARAMETER_STORE_TAGS = local.parameter_store_tags - SUBNET_IDS = join(",", var.subnet_ids) - ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.enable_on_demand_failover_for_errors) - SCALE_ERRORS = jsonencode(var.scale_errors) - JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) - USE_DEDICATED_HOST = var.use_dedicated_host + AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name + DISABLE_RUNNER_AUTOUPDATE = var.disable_runner_autoupdate + ENABLE_EPHEMERAL_RUNNERS = var.enable_ephemeral_runners + ENABLE_JIT_CONFIG = var.enable_jit_config + ENABLE_JOB_QUEUED_CHECK = local.enable_job_queued_check + 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 + GHES_URL = var.ghes_url + USER_AGENT = var.user_agent + INSTANCE_ALLOCATION_STRATEGY = var.instance_allocation_strategy + INSTANCE_MAX_SPOT_PRICE = var.instance_max_spot_price + INSTANCE_TARGET_CAPACITY_TYPE = var.instance_target_capacity_type + INSTANCE_TYPE_PRIORITIES = var.instance_type_priorities != null ? jsonencode(var.instance_type_priorities) : "" + INSTANCE_TYPES = join(",", var.instance_types) + LAUNCH_TEMPLATE_NAME = aws_launch_template.runner.name + LOG_LEVEL = upper(var.log_level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.minimum_running_time_in_minutes, local.min_runtime_defaults[var.runner_os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = var.github_app_parameters.id.name + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github_app_parameters.key_base64.name + PARAMETER_GITHUB_APPS_MANIFEST_NAME = var.github_app_parameters.additional_apps_manifest != null ? var.github_app_parameters.additional_apps_manifest.name : "" + POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" + POWERTOOLS_METRICS_NAMESPACE = var.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + RUNNER_LABELS = lower(join(",", var.runner_labels)) + RUNNER_GROUP_NAME = var.runner_group_name + RUNNER_NAME_PREFIX = var.runner_name_prefix + COMPUTE_PROVIDER_TYPE = "ec2" + RUNNERS_MAXIMUM_COUNT = var.runners_maximum_count + POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-up" + SSM_TOKEN_PATH = local.token_path + SSM_CONFIG_PATH = "${var.ssm_paths.root}/${var.ssm_paths.config}" + SSM_PARAMETER_STORE_TAGS = local.parameter_store_tags + SUBNET_IDS = join(",", var.subnet_ids) + ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.enable_on_demand_failover_for_errors) + SCALE_ERRORS = jsonencode(var.scale_errors) + JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) + USE_DEDICATED_HOST = var.use_dedicated_host } } @@ -131,9 +131,9 @@ resource "aws_iam_role_policy" "scale_up" { environment = var.prefix sqs_arn = var.sqs_build_queue.arn github_app_parameter_arns = jsonencode(concat( - [for p in var.github_app_parameters.id : p.arn], - [for p in var.github_app_parameters.key_base64 : p.arn], - [for p in var.github_app_parameters.installation_id : p.arn if p != null], + [var.github_app_parameters.id.arn, var.github_app_parameters.key_base64.arn], + var.github_app_parameters.additional_app_parameter_arns, + var.github_app_parameters.additional_apps_manifest != null ? [var.github_app_parameters.additional_apps_manifest.arn] : [], ["arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}/*"] )) kms_key_arn = local.kms_key_arn diff --git a/modules/runners/tests/pool.tftest.hcl b/modules/runners/tests/pool.tftest.hcl index d6d327c598..c2282a7685 100644 --- a/modules/runners/tests/pool.tftest.hcl +++ b/modules/runners/tests/pool.tftest.hcl @@ -33,9 +33,8 @@ variables { runners_lambda_s3_key = "runners.zip" github_app_parameters = { - key_base64 = [{ name = "/github-runner/key-base64", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" }] - id = [{ name = "/github-runner/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" }] - installation_id = [null] + key_base64 = { name = "/github-runner/key-base64", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" } + id = { name = "/github-runner/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" } } ssm_paths = { diff --git a/modules/runners/variables.tf b/modules/runners/variables.tf index 946f9abf30..807a79bc93 100644 --- a/modules/runners/variables.tf +++ b/modules/runners/variables.tf @@ -229,20 +229,24 @@ variable "github_app_parameters" { description = <<-EOF Parameter Store for GitHub App Parameters. - Supports multiple GitHub Apps for random API rate limit distribution. - Each list element corresponds to one GitHub App and is a map containing - `name` and `arn` keys referencing SSM parameters. The first element is the - primary app (the one whose webhook secret is used for incoming webhook - validation). All apps must be installed on the same repositories/organizations. - - The control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly - select an app from the list for each GitHub API call, distributing rate - limit consumption across all configured apps. + Supports multiple GitHub Apps for API rate limit distribution. `id` and + `key_base64` reference the primary app (the one whose webhook secret is + used for incoming webhook validation). Additional apps are delivered to + the lambdas via `additional_apps_manifest`, an SSM parameter whose value + lists the per-app credential parameter names, keeping the lambda + environment size constant regardless of app count. + `additional_app_parameter_arns` carries the ARNs of every additional app + credential parameter for the lambda IAM policies. All apps must be + installed on the same repositories/organizations as the primary app. EOF type = object({ - key_base64 = list(map(string)) - id = list(map(string)) - installation_id = list(object({ name = string, arn = string })) + key_base64 = map(string) + id = map(string) + additional_apps_manifest = optional(object({ + name = string + arn = string + }), null) + additional_app_parameter_arns = optional(list(string), []) }) } diff --git a/modules/ssm/README.md b/modules/ssm/README.md index 9f95dd2d45..66bce354e2 100644 --- a/modules/ssm/README.md +++ b/modules/ssm/README.md @@ -29,6 +29,7 @@ No modules. | [aws_ssm_parameter.additional_github_app_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.additional_github_app_installation_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.additional_github_app_key_base64](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.additional_github_apps_manifest](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.github_app_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.github_app_key_base64](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.github_app_webhook_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | @@ -48,5 +49,6 @@ No modules. | Name | Description | |------|-------------| | [additional\_app\_parameters](#output\_additional\_app\_parameters) | n/a | +| [additional\_apps\_manifest](#output\_additional\_apps\_manifest) | n/a | | [parameters](#output\_parameters) | n/a | diff --git a/modules/ssm/outputs.tf b/modules/ssm/outputs.tf index 6032913520..e1afaf990e 100644 --- a/modules/ssm/outputs.tf +++ b/modules/ssm/outputs.tf @@ -33,3 +33,10 @@ output "additional_app_parameters" { } ] } + +output "additional_apps_manifest" { + value = length(var.additional_github_apps) > 0 ? { + name = aws_ssm_parameter.additional_github_apps_manifest[0].name + arn = aws_ssm_parameter.additional_github_apps_manifest[0].arn + } : null +} diff --git a/modules/ssm/ssm.tf b/modules/ssm/ssm.tf index 26ae6ea790..9467a136e5 100644 --- a/modules/ssm/ssm.tf +++ b/modules/ssm/ssm.tf @@ -51,3 +51,32 @@ resource "aws_ssm_parameter" "additional_github_app_installation_id" { key_id = local.kms_key_arn tags = var.tags } + +locals { + # Parameter names of every additional app credential, in app order. The + # manifest keeps the lambda environment size constant regardless of the + # number of configured apps: the lambdas receive only the manifest's + # parameter name and resolve the per-app parameter names from its value. + additional_apps_manifest = [ + for idx, app in var.additional_github_apps : { + idParamName = app.id_ssm != null ? app.id_ssm.name : aws_ssm_parameter.additional_github_app_id[tostring(idx)].name + keyParamName = app.key_base64_ssm != null ? app.key_base64_ssm.name : aws_ssm_parameter.additional_github_app_key_base64[tostring(idx)].name + installationIdParamName = ( + app.installation_id_ssm != null ? app.installation_id_ssm.name : + app.installation_id != null ? aws_ssm_parameter.additional_github_app_installation_id[tostring(idx)].name : + null + ) + } + ] +} + +resource "aws_ssm_parameter" "additional_github_apps_manifest" { + count = length(var.additional_github_apps) > 0 ? 1 : 0 + name = "${var.path_prefix}/additional_github_apps_manifest" + type = "String" + # Intelligent-Tiering upgrades the parameter to the advanced tier when the + # manifest outgrows the 4KB standard tier value limit (roughly 15 apps). + tier = "Intelligent-Tiering" + value = jsonencode(local.additional_apps_manifest) + tags = var.tags +} diff --git a/outputs.tf b/outputs.tf index 8e560e07f7..937714ff63 100644 --- a/outputs.tf +++ b/outputs.tf @@ -44,27 +44,15 @@ output "webhook" { } output "ssm_parameters" { - value = merge( - { - id = { name = local.github_app_parameters.id[0].name, arn = local.github_app_parameters.id[0].arn } - key_base64 = { name = local.github_app_parameters.key_base64[0].name, arn = local.github_app_parameters.key_base64[0].arn } - webhook_secret = { name = local.github_app_parameters.webhook_secret.name, arn = local.github_app_parameters.webhook_secret.arn } - }, - { for idx, v in local.github_app_parameters.id : "github_app_id_${idx}" => { - name = v.name - arn = v.arn - } }, - { for idx, v in local.github_app_parameters.key_base64 : "github_app_key_base64_${idx}" => { - name = v.name - arn = v.arn - } }, - { - github_app_webhook_secret = { - name = local.github_app_parameters.webhook_secret.name - arn = local.github_app_parameters.webhook_secret.arn - } - }, - ) + value = { + id = { name = local.github_app_parameters.id.name, arn = local.github_app_parameters.id.arn } + key_base64 = { name = local.github_app_parameters.key_base64.name, arn = local.github_app_parameters.key_base64.arn } + webhook_secret = { name = local.github_app_parameters.webhook_secret.name, arn = local.github_app_parameters.webhook_secret.arn } + additional_apps_manifest = local.github_app_parameters.additional_apps_manifest != null ? { + name = local.github_app_parameters.additional_apps_manifest.name + arn = local.github_app_parameters.additional_apps_manifest.arn + } : null + } }