Skip to content

Commit 19c2869

Browse files
committed
feat(github-app): allow several github apps to be used
1 parent 6dc97d5 commit 19c2869

36 files changed

+769
-235
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
@@ -5,7 +5,7 @@ import { RequestInterface, RequestParameters } from '@octokit/types';
55
import { getParameter } from '@aws-github-runner/aws-ssm-util';
66
import * as nock from 'nock';
77

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

1111
type MockProxy<T> = T & {
@@ -31,6 +31,7 @@ const mockedGet = vi.mocked(getParameter);
3131
beforeEach(() => {
3232
vi.resetModules();
3333
vi.clearAllMocks();
34+
resetAppCredentialsCache();
3435
process.env = { ...cleanEnv };
3536
process.env.PARAMETER_GITHUB_APP_ID_NAME = PARAMETER_GITHUB_APP_ID_NAME;
3637
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = PARAMETER_GITHUB_APP_KEY_BASE64_NAME;
@@ -207,3 +208,76 @@ ${decryptedValue}`,
207208
expect(result.token).toBe(token);
208209
});
209210
});
211+
212+
describe('Test getStoredInstallationId', () => {
213+
const decryptedValue = 'decryptedValue';
214+
const b64 = Buffer.from(decryptedValue, 'binary').toString('base64');
215+
216+
beforeEach(() => {
217+
const mockedAuth = vi.fn();
218+
mockedAuth.mockResolvedValue({ token: 'token' });
219+
const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
220+
vi.mocked(createAppAuth).mockReturnValue(mockWithHook);
221+
});
222+
223+
it('returns stored installation ID when configured', async () => {
224+
const installationIdParam = `/actions-runner/${ENVIRONMENT}/github_app_installation_id`;
225+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = installationIdParam;
226+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64).mockResolvedValueOnce('12345');
227+
228+
const result = await getStoredInstallationId(0);
229+
expect(result).toBe(12345);
230+
expect(getParameter).toHaveBeenCalledWith(installationIdParam);
231+
});
232+
233+
it('returns undefined when installation ID param is empty', async () => {
234+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
235+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64);
236+
237+
const result = await getStoredInstallationId(0);
238+
expect(result).toBeUndefined();
239+
});
240+
241+
it('returns undefined when env var is not set', async () => {
242+
delete process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME;
243+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64);
244+
245+
const result = await getStoredInstallationId(0);
246+
expect(result).toBeUndefined();
247+
});
248+
249+
it('returns undefined for out-of-bounds appIndex', async () => {
250+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
251+
mockedGet.mockResolvedValueOnce(GITHUB_APP_ID).mockResolvedValueOnce(b64);
252+
253+
const result = await getStoredInstallationId(99);
254+
expect(result).toBeUndefined();
255+
});
256+
257+
it('loads installation IDs for multi-app setup', async () => {
258+
const app1IdParam = `/actions-runner/${ENVIRONMENT}/github_app_id`;
259+
const app2IdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_id`;
260+
const app1KeyParam = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`;
261+
const app2KeyParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_key_base64`;
262+
const app2InstallParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_installation_id`;
263+
264+
process.env.PARAMETER_GITHUB_APP_ID_NAME = `${app1IdParam}:${app2IdParam}`;
265+
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `${app1KeyParam}:${app2KeyParam}`;
266+
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = `:${app2InstallParam}`;
267+
268+
mockedGet
269+
.mockResolvedValueOnce('1') // app1 id
270+
.mockResolvedValueOnce(b64) // app1 key
271+
.mockResolvedValueOnce('2') // app2 id
272+
.mockResolvedValueOnce(b64) // app2 key
273+
.mockResolvedValueOnce('67890'); // app2 installation id
274+
275+
// Primary app (index 0) has no stored installation ID
276+
const result0 = await getStoredInstallationId(0);
277+
expect(result0).toBeUndefined();
278+
279+
// Additional app (index 1) has stored installation ID
280+
const result1 = await getStoredInstallationId(1);
281+
expect(result1).toBe(67890);
282+
});
283+
});

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

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,56 @@ import { EndpointDefaults } from '@octokit/types';
2525

2626
const logger = createChildLogger('gh-auth');
2727

28+
interface GitHubAppCredential {
29+
appId: number;
30+
privateKey: string;
31+
installationId?: number;
32+
}
33+
34+
let appCredentialsPromise: Promise<GitHubAppCredential[]> | null = null;
35+
36+
async function loadAppCredentials(): Promise<GitHubAppCredential[]> {
37+
const idParams = process.env.PARAMETER_GITHUB_APP_ID_NAME.split(':').filter(Boolean);
38+
const keyParams = process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME.split(':').filter(Boolean);
39+
const installationIdParams = (process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME || '').split(':');
40+
if (idParams.length !== keyParams.length) {
41+
throw new Error(`GitHub App parameter count mismatch: ${idParams.length} IDs vs ${keyParams.length} keys`);
42+
}
43+
const credentials: GitHubAppCredential[] = [];
44+
for (let i = 0; i < idParams.length; i++) {
45+
const appId = parseInt(await getParameter(idParams[i]));
46+
const privateKey = Buffer.from(await getParameter(keyParams[i]), 'base64')
47+
.toString()
48+
.replace('/[\\n]/g', String.fromCharCode(10));
49+
const installationIdParam = installationIdParams[i];
50+
const installationId =
51+
installationIdParam && installationIdParam.length > 0
52+
? parseInt(await getParameter(installationIdParam))
53+
: undefined;
54+
credentials.push({ appId, privateKey, installationId });
55+
}
56+
logger.info(`Loaded ${credentials.length} GitHub App credential(s)`);
57+
return credentials;
58+
}
59+
60+
function getAppCredentials(): Promise<GitHubAppCredential[]> {
61+
if (!appCredentialsPromise) appCredentialsPromise = loadAppCredentials();
62+
return appCredentialsPromise;
63+
}
64+
65+
export async function getAppCount(): Promise<number> {
66+
return (await getAppCredentials()).length;
67+
}
68+
69+
export function resetAppCredentialsCache(): void {
70+
appCredentialsPromise = null;
71+
}
72+
73+
export async function getStoredInstallationId(appIndex: number): Promise<number | undefined> {
74+
const credentials = await getAppCredentials();
75+
return credentials[appIndex]?.installationId;
76+
}
77+
2878
export async function createOctokitClient(token: string, ghesApiUrl = ''): Promise<Octokit> {
2979
const CustomOctokit = Octokit.plugin(throttling);
3080
const ocktokitOptions: OctokitOptions = {
@@ -54,35 +104,38 @@ export async function createOctokitClient(token: string, ghesApiUrl = ''): Promi
54104
export async function createGithubAppAuth(
55105
installationId: number | undefined,
56106
ghesApiUrl = '',
57-
): Promise<AppAuthentication> {
58-
const auth = await createAuth(installationId, ghesApiUrl);
59-
const appAuthOptions: AppAuthOptions = { type: 'app' };
60-
return auth(appAuthOptions);
107+
appIndex?: number,
108+
): Promise<AppAuthentication & { appIndex: number }> {
109+
const credentials = await getAppCredentials();
110+
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
111+
const auth = await createAuth(installationId, ghesApiUrl, idx);
112+
const result = await auth({ type: 'app' });
113+
return { ...result, appIndex: idx };
61114
}
62115

63116
export async function createGithubInstallationAuth(
64117
installationId: number | undefined,
65118
ghesApiUrl = '',
119+
appIndex?: number,
66120
): Promise<InstallationAccessTokenAuthentication> {
67-
const auth = await createAuth(installationId, ghesApiUrl);
68-
const installationAuthOptions: InstallationAuthOptions = { type: 'installation', installationId };
69-
return auth(installationAuthOptions);
121+
const credentials = await getAppCredentials();
122+
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
123+
const auth = await createAuth(installationId, ghesApiUrl, idx);
124+
return auth({ type: 'installation', installationId });
70125
}
71126

72-
async function createAuth(installationId: number | undefined, ghesApiUrl: string): Promise<AuthInterface> {
73-
const appId = parseInt(await getParameter(process.env.PARAMETER_GITHUB_APP_ID_NAME));
74-
let authOptions: StrategyOptions = {
75-
appId,
76-
privateKey: Buffer.from(
77-
await getParameter(process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME),
78-
'base64',
79-
// replace literal \n characters with new lines to allow the key to be stored as a
80-
// single line variable. This logic should match how the GitHub Terraform provider
81-
// processes private keys to retain compatibility between the projects
82-
)
83-
.toString()
84-
.replace('/[\\n]/g', String.fromCharCode(10)),
85-
};
127+
async function createAuth(
128+
installationId: number | undefined,
129+
ghesApiUrl: string,
130+
appIndex?: number,
131+
): Promise<AuthInterface> {
132+
const credentials = await getAppCredentials();
133+
const selected =
134+
appIndex !== undefined ? credentials[appIndex] : credentials[Math.floor(Math.random() * credentials.length)];
135+
136+
logger.debug(`Selected GitHub App ${selected.appId} for authentication`);
137+
138+
let authOptions: StrategyOptions = { appId: selected.appId, privateKey: selected.privateKey };
86139
if (installationId) authOptions = { ...authOptions, installationId };
87140

88141
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)