Skip to content

Commit 764baea

Browse files
committed
feat(github-app): allow several github apps to be used
1 parent d5efde5 commit 764baea

36 files changed

+769
-231
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 { getParameter } 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 mockedGet = vi.mocked(getParameter);
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;
@@ -230,3 +231,76 @@ describe('Test createGithubAppAuth', () => {
230231
expect(result.token).toBe(token);
231232
});
232233
});
234+
235+
describe('Test getStoredInstallationId', () => {
236+
const decryptedValue = 'decryptedValue';
237+
const b64 = Buffer.from(decryptedValue, 'binary').toString('base64');
238+
239+
beforeEach(() => {
240+
const mockedAuth = vi.fn();
241+
mockedAuth.mockResolvedValue({ token: 'token' });
242+
const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
243+
vi.mocked(createAppAuth).mockReturnValue(mockWithHook);
244+
});
245+
246+
it('returns stored installation ID when configured', async () => {
247+
const installationIdParam = `/actions-runner/${ENVIRONMENT}/github_app_installation_id`;
248+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = installationIdParam;
249+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64).mockResolvedValueOnce('12345');
250+
251+
const result = await getStoredInstallationId(0);
252+
expect(result).toBe(12345);
253+
expect(getParameter).toHaveBeenCalledWith(installationIdParam);
254+
});
255+
256+
it('returns undefined when installation ID param is empty', async () => {
257+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
258+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64);
259+
260+
const result = await getStoredInstallationId(0);
261+
expect(result).toBeUndefined();
262+
});
263+
264+
it('returns undefined when env var is not set', async () => {
265+
delete process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME;
266+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64);
267+
268+
const result = await getStoredInstallationId(0);
269+
expect(result).toBeUndefined();
270+
});
271+
272+
it('returns undefined for out-of-bounds appIndex', async () => {
273+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
274+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64);
275+
276+
const result = await getStoredInstallationId(99);
277+
expect(result).toBeUndefined();
278+
});
279+
280+
it('loads installation IDs for multi-app setup', async () => {
281+
const app1IdParam = `/actions-runner/${ENVIRONMENT}/github_app_id`;
282+
const app2IdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_id`;
283+
const app1KeyParam = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`;
284+
const app2KeyParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_key_base64`;
285+
const app2InstallParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_installation_id`;
286+
287+
process.env.PARAMETER_GITHUB_APP_ID_NAME = `${app1IdParam}:${app2IdParam}`;
288+
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `${app1KeyParam}:${app2KeyParam}`;
289+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = `:${app2InstallParam}`;
290+
291+
mockedGet
292+
.mockResolvedValueOnce('1') // app1 id
293+
.mockResolvedValueOnce(b64) // app1 key
294+
.mockResolvedValueOnce('2') // app2 id
295+
.mockResolvedValueOnce(b64) // app2 key
296+
.mockResolvedValueOnce('67890'); // app2 installation id
297+
298+
// Primary app (index 0) has no stored installation ID
299+
const result0 = await getStoredInstallationId(0);
300+
expect(result0).toBeUndefined();
301+
302+
// Additional app (index 1) has stored installation ID
303+
const result1 = await getStoredInstallationId(1);
304+
expect(result1).toBe(67890);
305+
});
306+
});

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

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,56 @@ 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+
const credentials: GitHubAppCredential[] = [];
46+
for (let i = 0; i < idParams.length; i++) {
47+
const appId = parseInt(await getParameter(idParams[i]));
48+
const privateKey = Buffer.from(await getParameter(keyParams[i]), 'base64')
49+
.toString()
50+
.replace('/[\\n]/g', String.fromCharCode(10));
51+
const installationIdParam = installationIdParams[i];
52+
const installationId =
53+
installationIdParam && installationIdParam.length > 0
54+
? parseInt(await getParameter(installationIdParam))
55+
: undefined;
56+
credentials.push({ appId, privateKey, installationId });
57+
}
58+
logger.info(`Loaded ${credentials.length} GitHub App credential(s)`);
59+
return credentials;
60+
}
61+
62+
function getAppCredentials(): Promise<GitHubAppCredential[]> {
63+
if (!appCredentialsPromise) appCredentialsPromise = loadAppCredentials();
64+
return appCredentialsPromise;
65+
}
66+
67+
export async function getAppCount(): Promise<number> {
68+
return (await getAppCredentials()).length;
69+
}
70+
71+
export function resetAppCredentialsCache(): void {
72+
appCredentialsPromise = null;
73+
}
74+
75+
export async function getStoredInstallationId(appIndex: number): Promise<number | undefined> {
76+
const credentials = await getAppCredentials();
77+
return credentials[appIndex]?.installationId;
78+
}
79+
3080
export async function createOctokitClient(token: string, ghesApiUrl = ''): Promise<Octokit> {
3181
const CustomOctokit = Octokit.plugin(retry, throttling);
3282
const ocktokitOptions: OctokitOptions = {
@@ -67,19 +117,24 @@ export async function createOctokitClient(token: string, ghesApiUrl = ''): Promi
67117
export async function createGithubAppAuth(
68118
installationId: number | undefined,
69119
ghesApiUrl = '',
70-
): Promise<AppAuthentication> {
71-
const auth = await createAuth(installationId, ghesApiUrl);
72-
const appAuthOptions: AppAuthOptions = { type: 'app' };
73-
return auth(appAuthOptions);
120+
appIndex?: number,
121+
): Promise<AppAuthentication & { appIndex: number }> {
122+
const credentials = await getAppCredentials();
123+
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
124+
const auth = await createAuth(installationId, ghesApiUrl, idx);
125+
const result = await auth({ type: 'app' });
126+
return { ...result, appIndex: idx };
74127
}
75128

76129
export async function createGithubInstallationAuth(
77130
installationId: number | undefined,
78131
ghesApiUrl = '',
132+
appIndex?: number,
79133
): Promise<InstallationAccessTokenAuthentication> {
80-
const auth = await createAuth(installationId, ghesApiUrl);
81-
const installationAuthOptions: InstallationAuthOptions = { type: 'installation', installationId };
82-
return auth(installationAuthOptions);
134+
const credentials = await getAppCredentials();
135+
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
136+
const auth = await createAuth(installationId, ghesApiUrl, idx);
137+
return auth({ type: 'installation', installationId });
83138
}
84139

85140
function signJwt(payload: Record<string, unknown>, privateKey: string): string {
@@ -90,14 +145,16 @@ function signJwt(payload: Record<string, unknown>, privateKey: string): string {
90145
return `${message}.${signature}`;
91146
}
92147

93-
async function createAuth(installationId: number | undefined, ghesApiUrl: string): Promise<AuthInterface> {
94-
const appId = parseInt(await getParameter(process.env.PARAMETER_GITHUB_APP_ID_NAME));
95-
// replace literal \n characters with new lines to allow the key to be stored as a
96-
// single line variable. This logic should match how the GitHub Terraform provider
97-
// processes private keys to retain compatibility between the projects
98-
const privateKey = Buffer.from(await getParameter(process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME), 'base64')
99-
.toString()
100-
.replace('/[\\n]/g', String.fromCharCode(10));
148+
async function createAuth(
149+
installationId: number | undefined,
150+
ghesApiUrl: string,
151+
appIndex?: number,
152+
): Promise<AuthInterface> {
153+
const credentials = await getAppCredentials();
154+
const selected =
155+
appIndex !== undefined ? credentials[appIndex] : credentials[Math.floor(Math.random() * credentials.length)];
156+
157+
logger.debug(`Selected GitHub App ${selected.appId} for authentication`);
101158

102159
// Use a custom createJwt callback to include a jti (JWT ID) claim in every token.
103160
// Without this, concurrent Lambda invocations generating JWTs within the same second
@@ -107,11 +164,11 @@ async function createAuth(installationId: number | undefined, ghesApiUrl: string
107164
const now = Math.floor(Date.now() / 1000) + (timeDifference ?? 0);
108165
const iat = now - 30;
109166
const exp = iat + 600;
110-
const jwt = signJwt({ iat, exp, iss: appId, jti: randomUUID() }, privateKey);
167+
const jwt = signJwt({ iat, exp, iss: appId, jti: randomUUID() }, selected.privateKey);
111168
return { jwt, expiresAt: new Date(exp * 1000).toISOString() };
112169
};
113170

114-
let authOptions: StrategyOptions = { appId, createJwt };
171+
let authOptions: StrategyOptions = { appId: selected.appId, createJwt };
115172
if (installationId) authOptions = { ...authOptions, installationId };
116173

117174
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
}

lambdas/functions/control-plane/src/pool/pool.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ vi.mock('./../github/auth', async () => ({
3434
createGithubAppAuth: vi.fn(),
3535
createGithubInstallationAuth: vi.fn(),
3636
createOctokitClient: vi.fn(),
37+
getStoredInstallationId: vi.fn().mockResolvedValue(undefined),
3738
}));
3839

3940
vi.mock('../scale-runners/scale-up', async () => ({
@@ -166,6 +167,7 @@ beforeEach(() => {
166167
token: 'token',
167168
appId: 1,
168169
expiresAt: 'some-date',
170+
appIndex: 0,
169171
});
170172
mockedInstallationAuth.mockResolvedValue({
171173
type: 'token',
@@ -336,4 +338,46 @@ describe('Test simple pool.', () => {
336338
expect(createRunners).toHaveBeenCalledWith(expect.anything(), expect.anything(), 1, expect.anything());
337339
});
338340
});
341+
342+
describe('Multi-app round-robin', () => {
343+
beforeEach(() => {
344+
(getGitHubEnterpriseApiUrl as ReturnType<typeof vi.fn>).mockReturnValue({
345+
ghesApiUrl: '',
346+
ghesBaseUrl: '',
347+
});
348+
});
349+
350+
it('passes the same appIndex to createGithubInstallationAuth', async () => {
351+
mockedAppAuth.mockResolvedValue({
352+
type: 'app',
353+
token: 'token',
354+
appId: 42,
355+
expiresAt: 'some-date',
356+
appIndex: 1,
357+
});
358+
359+
await adjust({ poolSize: 3 });
360+
361+
expect(mockedInstallationAuth).toHaveBeenCalledWith(
362+
expect.any(Number),
363+
expect.any(String),
364+
1, // appIndex must match the one from createGithubAppAuth
365+
);
366+
});
367+
368+
it('looks up installationId using the selected app JWT', async () => {
369+
mockedAppAuth.mockResolvedValue({
370+
type: 'app',
371+
token: 'app-token-for-selected-app',
372+
appId: 42,
373+
expiresAt: 'some-date',
374+
appIndex: 1,
375+
});
376+
377+
await adjust({ poolSize: 3 });
378+
379+
// Should look up installationId via the API
380+
expect(mockOctokit.apps.getOrgInstallation).toHaveBeenCalledWith({ org: ORG });
381+
});
382+
});
339383
});

0 commit comments

Comments
 (0)