Skip to content

Commit 7ef38bd

Browse files
refactor(storage): resolve providers at operation boundaries
1 parent 7a8278e commit 7ef38bd

27 files changed

Lines changed: 433 additions & 263 deletions
Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
import { createControlPlaneProviderRegistry } from '@aws-github-runner/compute-providers/control-plane';
22
import { computeProviderTypes } from '@aws-github-runner/compute-providers/provider-types';
3+
import type { CreateStartRunnerConfig } from '@aws-github-runner/compute-providers/core';
4+
import type { StorageProviderType } from '@aws-github-runner/storage-providers';
35

46
import { createStartRunnerConfig } from './scale-runners/github-runner';
57

6-
export const controlPlaneProviderRegistry = createControlPlaneProviderRegistry(createStartRunnerConfig);
8+
const providerRegistries = new Map<StorageProviderType, ReturnType<typeof createControlPlaneProviderRegistry>>();
9+
10+
export function getControlPlaneProviderRegistry(storageProviderType: StorageProviderType) {
11+
let registry = providerRegistries.get(storageProviderType);
12+
if (!registry) {
13+
const boundCreateStartRunnerConfig: CreateStartRunnerConfig = (...args) =>
14+
createStartRunnerConfig(storageProviderType, ...args);
15+
registry = createControlPlaneProviderRegistry(boundCreateStartRunnerConfig);
16+
providerRegistries.set(storageProviderType, registry);
17+
}
18+
return registry;
19+
}
720

821
export { computeProviderTypes };

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

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as nock from 'nock';
99
import {
1010
createGithubAppAuth,
1111
createOctokitClient,
12-
getStoredAppId,
12+
getAppId,
1313
getStoredInstallationId,
1414
onRateLimit,
1515
onSecondaryRateLimit,
@@ -34,6 +34,7 @@ const ENVIRONMENT = 'dev';
3434
const GITHUB_APP_ID = '1';
3535
const PARAMETER_GITHUB_APP_ID_NAME = `/actions-runner/${ENVIRONMENT}/github_app_id`;
3636
const PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`;
37+
const storageProviderType = 'aws_ssm' as const;
3738

3839
const mockedGetParameters = vi.mocked(getParameters);
3940

@@ -90,7 +91,7 @@ describe('Test createGithubAppAuth', () => {
9091
it('Throws early when PARAMETER_GITHUB_APP_ID_NAME is not set', async () => {
9192
delete process.env.PARAMETER_GITHUB_APP_ID_NAME;
9293

93-
await expect(createGithubAppAuth(installationId)).rejects.toThrow(
94+
await expect(createGithubAppAuth(storageProviderType, installationId)).rejects.toThrow(
9495
'Environment variable PARAMETER_GITHUB_APP_ID_NAME is not set',
9596
);
9697
expect(mockedGetParameters).not.toHaveBeenCalled();
@@ -99,7 +100,7 @@ describe('Test createGithubAppAuth', () => {
99100
it('Throws early when PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set', async () => {
100101
delete process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME;
101102

102-
await expect(createGithubAppAuth(installationId)).rejects.toThrow(
103+
await expect(createGithubAppAuth(storageProviderType, installationId)).rejects.toThrow(
103104
'Environment variable PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set',
104105
);
105106
expect(mockedGetParameters).not.toHaveBeenCalled();
@@ -120,7 +121,7 @@ describe('Test createGithubAppAuth', () => {
120121
mockedCreatAppAuth.mockReturnValue(mockWithHook);
121122

122123
// Act
123-
await createGithubAppAuth(installationId);
124+
await createGithubAppAuth(storageProviderType, installationId);
124125

125126
// Assert
126127
expect(mockedCreatAppAuth).toBeCalledTimes(1);
@@ -155,7 +156,7 @@ describe('Test createGithubAppAuth', () => {
155156
});
156157

157158
// Act
158-
await createGithubAppAuth(installationId);
159+
await createGithubAppAuth(storageProviderType, installationId);
159160

160161
// Generate two JWTs and verify they are different (jti makes them unique)
161162
const jwt1 = await capturedCreateJwt!(1);
@@ -192,7 +193,7 @@ describe('Test createGithubAppAuth', () => {
192193
mockedCreatAppAuth.mockReturnValue(mockWithHook);
193194

194195
// Act
195-
const result = await createGithubAppAuth(installationId);
196+
const result = await createGithubAppAuth(storageProviderType, installationId);
196197

197198
// Assert
198199
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);
@@ -216,7 +217,7 @@ describe('Test createGithubAppAuth', () => {
216217
mockedCreatAppAuth.mockReturnValue(mockWithHook);
217218

218219
// Act
219-
const result = await createGithubAppAuth(installationId);
220+
const result = await createGithubAppAuth(storageProviderType, installationId);
220221

221222
// Assert
222223
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);
@@ -253,7 +254,7 @@ describe('Test createGithubAppAuth', () => {
253254
});
254255

255256
// Act
256-
const result = await createGithubAppAuth(installationId, githubServerUrl);
257+
const result = await createGithubAppAuth(storageProviderType, installationId, githubServerUrl);
257258

258259
// Assert
259260
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);
@@ -291,7 +292,7 @@ describe('Test createGithubAppAuth', () => {
291292
mockedCreatAppAuth.mockReturnValue(mockWithHook);
292293

293294
// Act
294-
const result = await createGithubAppAuth(installationId, githubServerUrl);
295+
const result = await createGithubAppAuth(storageProviderType, installationId, githubServerUrl);
295296

296297
// Assert
297298
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);
@@ -353,7 +354,7 @@ describe('Test getStoredInstallationId', () => {
353354
]),
354355
);
355356

356-
const result = await getStoredInstallationId(0);
357+
const result = await getStoredInstallationId(storageProviderType, 0);
357358
expect(result).toBe(12345);
358359
});
359360

@@ -368,11 +369,24 @@ describe('Test getStoredInstallationId', () => {
368369
]),
369370
);
370371

371-
await expect(getStoredAppId(0)).resolves.toBe(GITHUB_APP_ID);
372-
await expect(getStoredInstallationId(0)).resolves.toBe(12345);
372+
await expect(getAppId(storageProviderType, 0)).resolves.toBe(GITHUB_APP_ID);
373+
await expect(getStoredInstallationId(storageProviderType, 0)).resolves.toBe(12345);
373374
expect(mockedGetParameters).toHaveBeenCalledTimes(1);
374375
});
375376

377+
it('retries credential loading after a cached request rejects', async () => {
378+
mockedGetParameters.mockRejectedValueOnce(new Error('temporary storage failure')).mockResolvedValueOnce(
379+
new Map([
380+
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
381+
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
382+
]),
383+
);
384+
385+
await expect(getAppId(storageProviderType)).rejects.toThrow('temporary storage failure');
386+
await expect(getAppId(storageProviderType)).resolves.toBe(GITHUB_APP_ID);
387+
expect(mockedGetParameters).toHaveBeenCalledTimes(2);
388+
});
389+
376390
it('returns undefined when installation ID param is empty', async () => {
377391
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
378392
mockedGetParameters.mockResolvedValueOnce(
@@ -382,7 +396,7 @@ describe('Test getStoredInstallationId', () => {
382396
]),
383397
);
384398

385-
const result = await getStoredInstallationId(0);
399+
const result = await getStoredInstallationId(storageProviderType, 0);
386400
expect(result).toBeUndefined();
387401
});
388402

@@ -395,7 +409,7 @@ describe('Test getStoredInstallationId', () => {
395409
]),
396410
);
397411

398-
const result = await getStoredInstallationId(0);
412+
const result = await getStoredInstallationId(storageProviderType, 0);
399413
expect(result).toBeUndefined();
400414
});
401415

@@ -408,7 +422,7 @@ describe('Test getStoredInstallationId', () => {
408422
]),
409423
);
410424

411-
const result = await getStoredInstallationId(99);
425+
const result = await getStoredInstallationId(storageProviderType, 99);
412426
expect(result).toBeUndefined();
413427
});
414428

@@ -434,11 +448,11 @@ describe('Test getStoredInstallationId', () => {
434448
);
435449

436450
// Primary app (index 0) has no stored installation ID
437-
const result0 = await getStoredInstallationId(0);
451+
const result0 = await getStoredInstallationId(storageProviderType, 0);
438452
expect(result0).toBeUndefined();
439453

440454
// Additional app (index 1) has stored installation ID
441-
const result1 = await getStoredInstallationId(1);
455+
const result1 = await getStoredInstallationId(storageProviderType, 1);
442456
expect(result1).toBe(67890);
443457
});
444458
});

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

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ import { retry } from '@octokit/plugin-retry';
2323
import { throttling } from '@octokit/plugin-throttling';
2424
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
2525
import { controlPlaneStorageProviderRegistry } from '@aws-github-runner/storage-providers/control-plane';
26-
import { resolveStorageProviderType } from '@aws-github-runner/storage-providers/provider-types';
27-
import type { GitHubAppCredential } from '@aws-github-runner/storage-providers';
26+
import type { GitHubAppCredential, StorageProviderType } from '@aws-github-runner/storage-providers';
2827
import { EndpointDefaults } from '@octokit/types';
2928

3029
const logger = createChildLogger('gh-auth');
@@ -71,39 +70,46 @@ export function onSecondaryRateLimit(
7170
return retryCount < MAX_SECONDARY_RATE_LIMIT_RETRIES;
7271
}
7372

74-
let appCredentialsPromise: Promise<GitHubAppCredential[]> | null = null;
75-
76-
async function loadAppCredentials(): Promise<GitHubAppCredential[]> {
77-
const storageProviderType = resolveStorageProviderType(process.env.RUNNER_STORAGE_PROVIDER);
78-
const credentialsReader = controlPlaneStorageProviderRegistry.capability(
79-
storageProviderType,
80-
'githubAppCredentialsReader',
81-
)();
82-
const credentials = await credentialsReader.read();
83-
logger.info(`Loaded ${credentials.length} GitHub App credential(s)`);
84-
return credentials;
85-
}
86-
87-
function getAppCredentials(): Promise<GitHubAppCredential[]> {
88-
if (!appCredentialsPromise) appCredentialsPromise = loadAppCredentials();
89-
return appCredentialsPromise;
90-
}
73+
const appCredentialsPromises = new Map<StorageProviderType, Promise<GitHubAppCredential[]>>();
74+
75+
async function getAppCredentials(storageProviderType: StorageProviderType): Promise<GitHubAppCredential[]> {
76+
let credentialsPromise = appCredentialsPromises.get(storageProviderType);
77+
if (!credentialsPromise) {
78+
const credentialsReader = controlPlaneStorageProviderRegistry.capability(
79+
storageProviderType,
80+
'githubAppCredentialsReader',
81+
)();
82+
credentialsPromise = credentialsReader.read().then((credentials) => {
83+
logger.info(`Loaded ${credentials.length} GitHub App credential(s)`);
84+
return credentials;
85+
});
86+
appCredentialsPromises.set(storageProviderType, credentialsPromise);
87+
}
9188

92-
export async function getAppCount(): Promise<number> {
93-
return (await getAppCredentials()).length;
89+
try {
90+
return await credentialsPromise;
91+
} catch (error) {
92+
if (appCredentialsPromises.get(storageProviderType) === credentialsPromise) {
93+
appCredentialsPromises.delete(storageProviderType);
94+
}
95+
throw error;
96+
}
9497
}
9598

9699
export function resetAppCredentialsCache(): void {
97-
appCredentialsPromise = null;
100+
appCredentialsPromises.clear();
98101
}
99102

100-
export async function getStoredInstallationId(appIndex: number): Promise<number | undefined> {
101-
const credentials = await getAppCredentials();
103+
export async function getStoredInstallationId(
104+
storageProviderType: StorageProviderType,
105+
appIndex: number,
106+
): Promise<number | undefined> {
107+
const credentials = await getAppCredentials(storageProviderType);
102108
return credentials[appIndex]?.installationId;
103109
}
104110

105-
export async function getStoredAppId(appIndex = 0): Promise<string> {
106-
const credential = (await getAppCredentials())[appIndex];
111+
export async function getAppId(storageProviderType: StorageProviderType, appIndex = 0): Promise<string> {
112+
const credential = (await getAppCredentials(storageProviderType))[appIndex];
107113
if (!credential) {
108114
throw new Error(`GitHub App credential at index ${appIndex} not found`);
109115
}
@@ -142,25 +148,35 @@ export async function createOctokitClient(token: string, ghesApiUrl = ''): Promi
142148
}
143149

144150
export async function createGithubAppAuth(
151+
storageProviderType: StorageProviderType,
145152
installationId: number | undefined,
146153
ghesApiUrl = '',
147154
appIndex?: number,
148155
): Promise<AppAuthentication & { appIndex: number }> {
149-
const credentials = await getAppCredentials();
156+
const credentials = await getAppCredentials(storageProviderType);
150157
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
151-
const auth = await createAuth(installationId, ghesApiUrl, idx);
158+
const selected = credentials[idx];
159+
if (!selected) {
160+
throw new Error(`GitHub App credential at index ${idx} not found`);
161+
}
162+
const auth = createAuth(selected, installationId, ghesApiUrl);
152163
const result = await auth({ type: 'app' });
153164
return { ...result, appIndex: idx };
154165
}
155166

156167
export async function createGithubInstallationAuth(
168+
storageProviderType: StorageProviderType,
157169
installationId: number | undefined,
158170
ghesApiUrl = '',
159171
appIndex?: number,
160172
): Promise<InstallationAccessTokenAuthentication> {
161-
const credentials = await getAppCredentials();
173+
const credentials = await getAppCredentials(storageProviderType);
162174
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
163-
const auth = await createAuth(installationId, ghesApiUrl, idx);
175+
const selected = credentials[idx];
176+
if (!selected) {
177+
throw new Error(`GitHub App credential at index ${idx} not found`);
178+
}
179+
const auth = createAuth(selected, installationId, ghesApiUrl);
164180
return auth({ type: 'installation', installationId });
165181
}
166182

@@ -172,15 +188,11 @@ function signJwt(payload: Record<string, unknown>, privateKey: string): string {
172188
return `${message}.${signature}`;
173189
}
174190

175-
async function createAuth(
191+
function createAuth(
192+
selected: GitHubAppCredential,
176193
installationId: number | undefined,
177194
ghesApiUrl: string,
178-
appIndex?: number,
179-
): Promise<AuthInterface> {
180-
const credentials = await getAppCredentials();
181-
const selected =
182-
appIndex !== undefined ? credentials[appIndex] : credentials[Math.floor(Math.random() * credentials.length)];
183-
195+
): AuthInterface {
184196
logger.debug(`Selected GitHub App ${selected.appId} for authentication`);
185197

186198
// Use a custom createJwt callback to include a jti (JWT ID) claim in every token.

0 commit comments

Comments
 (0)