Skip to content

Commit dcb0cbf

Browse files
committed
feat(github-app): allow several github apps to be used
1 parent 1d57199 commit dcb0cbf

36 files changed

+783
-248
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
104104

105105
| Name | Description | Type | Default | Required |
106106
|------|-------------|------|---------|:--------:|
107+
| <a name="input_additional_github_apps"></a> [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for distributing API rate limit usage. Each must be installed on the same repos/orgs as the primary app. | <pre>list(object({<br/> key_base64 = optional(string)<br/> key_base64_ssm = optional(object({ arn = string, name = string }))<br/> id = optional(string)<br/> id_ssm = optional(object({ arn = string, name = string }))<br/> installation_id = optional(string)<br/> installation_id_ssm = optional(object({ arn = string, name = string }))<br/> }))</pre> | `[]` | no |
107108
| <a name="input_ami"></a> [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.<br/><br/>Parameters:<br/>- `filter`: Map of lists to filter AMIs by various criteria (e.g., { name = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"], state = ["available"] })<br/>- `owners`: List of AMI owners to limit the search. Common values: ["amazon"], ["self"], or specific AWS account IDs<br/>- `id_ssm_parameter_arn`: ARN of an SSM parameter containing the AMI ID. If specified, this overrides both AMI filter and parameter name<br/>- `kms_key_arn`: Optional KMS key ARN if the AMI is encrypted with a customer managed key<br/><br/>Defaults to null, in which case the module falls back to individual AMI variables (deprecated). | <pre>object({<br/> filter = optional(map(list(string)), { state = ["available"] })<br/> owners = optional(list(string), ["amazon"])<br/> id_ssm_parameter_arn = optional(string, null)<br/> kms_key_arn = optional(string, null)<br/> })</pre> | `null` | no |
108109
| <a name="input_ami_housekeeper_cleanup_config"></a> [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup.<br/><br/> `amiFilters` - Filters to use when searching for AMIs to cleanup. Default filter for images owned by the account and that are available.<br/> `dryRun` - If true, no AMIs will be deregistered. Default false.<br/> `launchTemplateNames` - Launch template names to use when searching for AMIs to cleanup. Default no launch templates.<br/> `maxItems` - The maximum number of AMIs that will be queried for cleanup. Default no maximum.<br/> `minimumDaysOld` - Minimum number of days old an AMI must be to be considered for cleanup. Default 30.<br/> `ssmParameterNames` - SSM parameter names to use when searching for AMIs to cleanup. This parameter should be set when using SSM to configure the AMI to use. Default no SSM parameters. | <pre>object({<br/> amiFilters = optional(list(object({<br/> Name = string<br/> Values = list(string)<br/> })),<br/> [{<br/> Name : "state",<br/> Values : ["available"],<br/> },<br/> {<br/> Name : "image-type",<br/> Values : ["machine"],<br/> }]<br/> )<br/> dryRun = optional(bool, false)<br/> launchTemplateNames = optional(list(string))<br/> maxItems = optional(number)<br/> minimumDaysOld = optional(number, 30)<br/> ssmParameterNames = optional(list(string))<br/> })</pre> | `{}` | no |
109110
| <a name="input_ami_housekeeper_lambda_s3_key"></a> [ami\_housekeeper\_lambda\_s3\_key](#input\_ami\_housekeeper\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no |

examples/multi-runner/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ For exact match, all the labels defined in the workflow should be present in the
1616

1717
For the list of provided runner configurations, there will be a single webhook and only a single GitHub App to receive the notifications for all types of workflow triggers.
1818

19+
## Multiple GitHub Apps (rate limit distribution)
20+
21+
This example also shows how to optionally configure multiple GitHub Apps via the `additional_github_apps` variable. When configured, the control-plane lambdas (scale-up, scale-down, pool, job-retry) randomly select an app for each GitHub API call, spreading the rate limit usage across all apps. Only the primary app needs a webhook URL configured in GitHub.
22+
1923
## Lambda distribution
2024

2125
Per combination of OS and architecture a lambda distribution syncer will be created. For this example there will be three instances (windows X64, linux X64, linux ARM).

examples/multi-runner/main.tf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,17 @@ module "runners" {
117117
webhook_secret = random_id.random.hex
118118
}
119119

120+
# Uncomment to distribute GitHub API rate limit usage across multiple GitHub Apps.
121+
# Each additional app must be installed on the same repos/orgs as the primary app.
122+
# The control-plane lambdas will randomly select an app for each API call.
123+
# additional_github_apps = [
124+
# {
125+
# key_base64 = var.additional_github_app_0.key_base64
126+
# id = var.additional_github_app_0.id
127+
# installation_id = var.additional_github_app_0.installation_id # optional, avoids an API call
128+
# },
129+
# ]
130+
120131
# Deploy webhook using the EventBridge
121132
eventbridge = {
122133
enable = true

lambdas/functions/control-plane/src/github/auth.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getParameters } from '@aws-github-runner/aws-ssm-util';
66
import { generateKeyPairSync } from 'node:crypto';
77
import * as nock from 'nock';
88

9-
import { createGithubAppAuth, createOctokitClient } from './auth';
9+
import { createGithubAppAuth, createOctokitClient, getStoredInstallationId, resetAppCredentialsCache } from './auth';
1010
import { describe, it, expect, beforeEach, vi } from 'vitest';
1111

1212
type MockProxy<T> = T & {
@@ -32,6 +32,7 @@ const mockedGetParameters = vi.mocked(getParameters);
3232
beforeEach(() => {
3333
vi.resetModules();
3434
vi.clearAllMocks();
35+
resetAppCredentialsCache();
3536
process.env = { ...cleanEnv };
3637
process.env.PARAMETER_GITHUB_APP_ID_NAME = PARAMETER_GITHUB_APP_ID_NAME;
3738
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = PARAMETER_GITHUB_APP_KEY_BASE64_NAME;
@@ -297,3 +298,76 @@ describe('Test createGithubAppAuth', () => {
297298
expect(result.token).toBe(token);
298299
});
299300
});
301+
302+
describe('Test getStoredInstallationId', () => {
303+
const decryptedValue = 'decryptedValue';
304+
const b64 = Buffer.from(decryptedValue, 'binary').toString('base64');
305+
306+
beforeEach(() => {
307+
const mockedAuth = vi.fn();
308+
mockedAuth.mockResolvedValue({ token: 'token' });
309+
const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
310+
vi.mocked(createAppAuth).mockReturnValue(mockWithHook);
311+
});
312+
313+
it('returns stored installation ID when configured', async () => {
314+
const installationIdParam = `/actions-runner/${ENVIRONMENT}/github_app_installation_id`;
315+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = installationIdParam;
316+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64).mockResolvedValueOnce('12345');
317+
318+
const result = await getStoredInstallationId(0);
319+
expect(result).toBe(12345);
320+
expect(getParameter).toHaveBeenCalledWith(installationIdParam);
321+
});
322+
323+
it('returns undefined when installation ID param is empty', async () => {
324+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
325+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64);
326+
327+
const result = await getStoredInstallationId(0);
328+
expect(result).toBeUndefined();
329+
});
330+
331+
it('returns undefined when env var is not set', async () => {
332+
delete process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME;
333+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64);
334+
335+
const result = await getStoredInstallationId(0);
336+
expect(result).toBeUndefined();
337+
});
338+
339+
it('returns undefined for out-of-bounds appIndex', async () => {
340+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
341+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64);
342+
343+
const result = await getStoredInstallationId(99);
344+
expect(result).toBeUndefined();
345+
});
346+
347+
it('loads installation IDs for multi-app setup', async () => {
348+
const app1IdParam = `/actions-runner/${ENVIRONMENT}/github_app_id`;
349+
const app2IdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_id`;
350+
const app1KeyParam = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`;
351+
const app2KeyParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_key_base64`;
352+
const app2InstallParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_installation_id`;
353+
354+
process.env.PARAMETER_GITHUB_APP_ID_NAME = `${app1IdParam}:${app2IdParam}`;
355+
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `${app1KeyParam}:${app2KeyParam}`;
356+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = `:${app2InstallParam}`;
357+
358+
mockedGet
359+
.mockResolvedValueOnce('1') // app1 id
360+
.mockResolvedValueOnce(b64) // app1 key
361+
.mockResolvedValueOnce('2') // app2 id
362+
.mockResolvedValueOnce(b64) // app2 key
363+
.mockResolvedValueOnce('67890'); // app2 installation id
364+
365+
// Primary app (index 0) has no stored installation ID
366+
const result0 = await getStoredInstallationId(0);
367+
expect(result0).toBeUndefined();
368+
369+
// Additional app (index 1) has stored installation ID
370+
const result1 = await getStoredInstallationId(1);
371+
expect(result1).toBe(67890);
372+
});
373+
});

lambdas/functions/control-plane/src/github/auth.ts

Lines changed: 90 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,73 @@ import { EndpointDefaults } from '@octokit/types';
2727

2828
const logger = createChildLogger('gh-auth');
2929

30+
interface GitHubAppCredential {
31+
appId: number;
32+
privateKey: string;
33+
installationId?: number;
34+
}
35+
36+
let appCredentialsPromise: Promise<GitHubAppCredential[]> | null = null;
37+
38+
async function loadAppCredentials(): Promise<GitHubAppCredential[]> {
39+
const idParams = process.env.PARAMETER_GITHUB_APP_ID_NAME.split(':').filter(Boolean);
40+
const keyParams = process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME.split(':').filter(Boolean);
41+
const installationIdParams = (process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME || '').split(':');
42+
if (idParams.length !== keyParams.length) {
43+
throw new Error(`GitHub App parameter count mismatch: ${idParams.length} IDs vs ${keyParams.length} keys`);
44+
}
45+
// Batch fetch all SSM parameters in a single call to reduce API calls
46+
const allParamNames = [
47+
...idParams,
48+
...keyParams,
49+
...installationIdParams.filter((p) => p.length > 0),
50+
];
51+
const params = await getParameters(allParamNames);
52+
53+
const credentials: GitHubAppCredential[] = [];
54+
for (let i = 0; i < idParams.length; i++) {
55+
const appIdValue = params.get(idParams[i]);
56+
if (!appIdValue) {
57+
throw new Error(`Parameter ${idParams[i]} not found`);
58+
}
59+
const appId = parseInt(appIdValue);
60+
const privateKeyBase64 = params.get(keyParams[i]);
61+
if (!privateKeyBase64) {
62+
throw new Error(`Parameter ${keyParams[i]} not found`);
63+
}
64+
// replace literal \n characters with new lines to allow the key to be stored as a
65+
// single line variable. This logic should match how the GitHub Terraform provider
66+
// processes private keys to retain compatibility between the projects
67+
const privateKey = Buffer.from(privateKeyBase64, 'base64').toString().replace('/[\\n]/g', String.fromCharCode(10));
68+
const installationIdParam = installationIdParams[i];
69+
const installationId =
70+
installationIdParam && installationIdParam.length > 0
71+
? parseInt(params.get(installationIdParam) || '')
72+
: undefined;
73+
credentials.push({ appId, privateKey, installationId });
74+
}
75+
logger.info(`Loaded ${credentials.length} GitHub App credential(s)`);
76+
return credentials;
77+
}
78+
79+
function getAppCredentials(): Promise<GitHubAppCredential[]> {
80+
if (!appCredentialsPromise) appCredentialsPromise = loadAppCredentials();
81+
return appCredentialsPromise;
82+
}
83+
84+
export async function getAppCount(): Promise<number> {
85+
return (await getAppCredentials()).length;
86+
}
87+
88+
export function resetAppCredentialsCache(): void {
89+
appCredentialsPromise = null;
90+
}
91+
92+
export async function getStoredInstallationId(appIndex: number): Promise<number | undefined> {
93+
const credentials = await getAppCredentials();
94+
return credentials[appIndex]?.installationId;
95+
}
96+
3097
export async function createOctokitClient(token: string, ghesApiUrl = ''): Promise<Octokit> {
3198
const CustomOctokit = Octokit.plugin(retry, throttling);
3299
const ocktokitOptions: OctokitOptions = {
@@ -67,19 +134,24 @@ export async function createOctokitClient(token: string, ghesApiUrl = ''): Promi
67134
export async function createGithubAppAuth(
68135
installationId: number | undefined,
69136
ghesApiUrl = '',
70-
): Promise<AppAuthentication> {
71-
const auth = await createAuth(installationId, ghesApiUrl);
72-
const appAuthOptions: AppAuthOptions = { type: 'app' };
73-
return auth(appAuthOptions);
137+
appIndex?: number,
138+
): Promise<AppAuthentication & { appIndex: number }> {
139+
const credentials = await getAppCredentials();
140+
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
141+
const auth = await createAuth(installationId, ghesApiUrl, idx);
142+
const result = await auth({ type: 'app' });
143+
return { ...result, appIndex: idx };
74144
}
75145

76146
export async function createGithubInstallationAuth(
77147
installationId: number | undefined,
78148
ghesApiUrl = '',
149+
appIndex?: number,
79150
): Promise<InstallationAccessTokenAuthentication> {
80-
const auth = await createAuth(installationId, ghesApiUrl);
81-
const installationAuthOptions: InstallationAuthOptions = { type: 'installation', installationId };
82-
return auth(installationAuthOptions);
151+
const credentials = await getAppCredentials();
152+
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
153+
const auth = await createAuth(installationId, ghesApiUrl, idx);
154+
return auth({ type: 'installation', installationId });
83155
}
84156

85157
function signJwt(payload: Record<string, unknown>, privateKey: string): string {
@@ -90,33 +162,16 @@ function signJwt(payload: Record<string, unknown>, privateKey: string): string {
90162
return `${message}.${signature}`;
91163
}
92164

93-
async function createAuth(installationId: number | undefined, ghesApiUrl: string): Promise<AuthInterface> {
94-
const appIdParamName = process.env.PARAMETER_GITHUB_APP_ID_NAME;
95-
const appKeyParamName = process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME;
96-
if (!appIdParamName) {
97-
throw new Error('Environment variable PARAMETER_GITHUB_APP_ID_NAME is not set');
98-
}
99-
if (!appKeyParamName) {
100-
throw new Error('Environment variable PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set');
101-
}
102-
103-
// Batch fetch both App ID and Private Key in a single SSM API call
104-
const paramNames = [appIdParamName, appKeyParamName];
105-
const params = await getParameters(paramNames);
106-
const appIdValue = params.get(appIdParamName);
107-
const privateKeyBase64 = params.get(appKeyParamName);
108-
if (!appIdValue) {
109-
throw new Error(`Parameter ${appIdParamName} not found`);
110-
}
111-
if (!privateKeyBase64) {
112-
throw new Error(`Parameter ${appKeyParamName} not found`);
113-
}
165+
async function createAuth(
166+
installationId: number | undefined,
167+
ghesApiUrl: string,
168+
appIndex?: number,
169+
): Promise<AuthInterface> {
170+
const credentials = await getAppCredentials();
171+
const selected =
172+
appIndex !== undefined ? credentials[appIndex] : credentials[Math.floor(Math.random() * credentials.length)];
114173

115-
const appId = parseInt(appIdValue);
116-
// replace literal \n characters with new lines to allow the key to be stored as a
117-
// single line variable. This logic should match how the GitHub Terraform provider
118-
// processes private keys to retain compatibility between the projects
119-
const privateKey = Buffer.from(privateKeyBase64, 'base64').toString().replace('/[\\n]/g', String.fromCharCode(10));
174+
logger.debug(`Selected GitHub App ${selected.appId} for authentication`);
120175

121176
// Use a custom createJwt callback to include a jti (JWT ID) claim in every token.
122177
// Without this, concurrent Lambda invocations generating JWTs within the same second
@@ -126,11 +181,11 @@ async function createAuth(installationId: number | undefined, ghesApiUrl: string
126181
const now = Math.floor(Date.now() / 1000) + (timeDifference ?? 0);
127182
const iat = now - 30;
128183
const exp = iat + 600;
129-
const jwt = signJwt({ iat, exp, iss: appId, jti: randomUUID() }, privateKey);
184+
const jwt = signJwt({ iat, exp, iss: appId, jti: randomUUID() }, selected.privateKey);
130185
return { jwt, expiresAt: new Date(exp * 1000).toISOString() };
131186
};
132187

133-
let authOptions: StrategyOptions = { appId, createJwt };
188+
let authOptions: StrategyOptions = { appId: selected.appId, createJwt };
134189
if (installationId) authOptions = { ...authOptions, installationId };
135190

136191
logger.debug(`GHES API URL: ${ghesApiUrl}`);

lambdas/functions/control-plane/src/github/octokit.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ vi.mock('../github/auth', async () => ({
1515
return { token: 'token', type: 'installation', installationId: installationId };
1616
}),
1717
createOctokitClient: vi.fn().mockImplementation(() => new Octokit()),
18-
createGithubAppAuth: vi.fn().mockResolvedValue({ token: 'token' }),
18+
createGithubAppAuth: vi.fn().mockResolvedValue({ token: 'token', appIndex: 0 }),
19+
getAppCount: vi.fn().mockResolvedValue(1),
20+
getStoredInstallationId: vi.fn().mockResolvedValue(undefined),
1921
}));
2022

2123
vi.mock('@octokit/rest', async () => ({

lambdas/functions/control-plane/src/github/octokit.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
11
import { Octokit } from '@octokit/rest';
22
import { ActionRequestMessage } from '../scale-runners/scale-up';
3-
import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from './auth';
3+
import {
4+
createGithubAppAuth,
5+
createGithubInstallationAuth,
6+
createOctokitClient,
7+
getAppCount,
8+
getStoredInstallationId,
9+
} from './auth';
410

511
export async function getInstallationId(
612
ghesApiUrl: string,
713
enableOrgLevel: boolean,
814
payload: ActionRequestMessage,
15+
appIndex?: number,
916
): Promise<number> {
10-
if (payload.installationId !== 0) {
17+
// Use pre-stored installation ID when available (avoids an API call)
18+
if (appIndex !== undefined) {
19+
const storedId = await getStoredInstallationId(appIndex);
20+
if (storedId !== undefined) return storedId;
21+
}
22+
23+
const multiApp = (await getAppCount()) > 1;
24+
25+
if (!multiApp && payload.installationId !== 0) {
1126
return payload.installationId;
1227
}
1328

14-
const ghAuth = await createGithubAppAuth(undefined, ghesApiUrl);
29+
const ghAuth = await createGithubAppAuth(undefined, ghesApiUrl, appIndex);
1530
const githubClient = await createOctokitClient(ghAuth.token, ghesApiUrl);
1631
return enableOrgLevel
1732
? (
@@ -40,7 +55,11 @@ export async function getOctokit(
4055
enableOrgLevel: boolean,
4156
payload: ActionRequestMessage,
4257
): Promise<Octokit> {
43-
const installationId = await getInstallationId(ghesApiUrl, enableOrgLevel, payload);
44-
const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl);
45-
return await createOctokitClient(ghAuth.token, ghesApiUrl);
58+
// Select one app for this entire auth flow
59+
const ghAuth = await createGithubAppAuth(undefined, ghesApiUrl);
60+
const appIdx = ghAuth.appIndex;
61+
62+
const installationId = await getInstallationId(ghesApiUrl, enableOrgLevel, payload, appIdx);
63+
const installationAuth = await createGithubInstallationAuth(installationId, ghesApiUrl, appIdx);
64+
return await createOctokitClient(installationAuth.token, ghesApiUrl);
4665
}

lambdas/functions/control-plane/src/github/rate-limit.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ let appIdPromise: Promise<string> | null = null;
99

1010
async function getAppId(): Promise<string> {
1111
if (!appIdPromise) {
12-
appIdPromise = getParameter(process.env.PARAMETER_GITHUB_APP_ID_NAME);
12+
const paramName = process.env.PARAMETER_GITHUB_APP_ID_NAME.split(':')[0];
13+
appIdPromise = getParameter(paramName);
1314
}
1415
return appIdPromise;
1516
}

0 commit comments

Comments
 (0)