Skip to content

Commit 5f34e10

Browse files
committed
feat: introduce github caching
1 parent c29838c commit 5f34e10

File tree

7 files changed

+150
-37
lines changed

7 files changed

+150
-37
lines changed

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { createAppAuth } from '@octokit/auth-app';
2-
import { StrategyOptions } from '@octokit/auth-app/dist-types/types';
1+
import { createAppAuth, type StrategyOptions } from '@octokit/auth-app';
32
import { request } from '@octokit/request';
43
import { RequestInterface, RequestParameters } from '@octokit/types';
54
import { getParameter } from '@aws-github-runner/aws-ssm-util';
65
import * as nock from 'nock';
7-
6+
import { reset } from './cache';
87
import { createGithubAppAuth, createOctokitClient } from './auth';
98
import { describe, it, expect, beforeEach, vi } from 'vitest';
109

@@ -29,6 +28,7 @@ const PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `/actions-runner/${ENVIRONMENT}/git
2928
const mockedGet = vi.mocked(getParameter);
3029

3130
beforeEach(() => {
31+
reset(); // clear all caches before each test
3232
vi.resetModules();
3333
vi.clearAllMocks();
3434
process.env = { ...cleanEnv };

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

Lines changed: 65 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,74 +22,107 @@ import { throttling } from '@octokit/plugin-throttling';
2222
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
2323
import { getParameter } from '@aws-github-runner/aws-ssm-util';
2424
import { EndpointDefaults } from '@octokit/types';
25+
import {
26+
getClient,
27+
getAppAuthObject,
28+
getInstallationAuthObject,
29+
getAuthConfig,
30+
createTokenCacheKey,
31+
createAuthCacheKey,
32+
createAuthConfigCacheKey,
33+
} from './cache';
34+
import type { GithubAppConfig } from './types';
2535

2636
const logger = createChildLogger('gh-auth');
2737

2838
export async function createOctokitClient(token: string, ghesApiUrl = ''): Promise<Octokit> {
29-
const CustomOctokit = Octokit.plugin(throttling);
30-
const ocktokitOptions: OctokitOptions = {
31-
auth: token,
32-
};
33-
if (ghesApiUrl) {
34-
ocktokitOptions.baseUrl = ghesApiUrl;
35-
ocktokitOptions.previews = ['antiope'];
36-
}
39+
const cacheKey = createTokenCacheKey(token, ghesApiUrl);
3740

38-
return new CustomOctokit({
39-
...ocktokitOptions,
40-
userAgent: process.env.USER_AGENT || 'github-aws-runners',
41-
throttle: {
42-
onRateLimit: (retryAfter: number, options: Required<EndpointDefaults>) => {
43-
logger.warn(
44-
`GitHub rate limit: Request quota exhausted for request ${options.method} ${options.url}. Requested `,
45-
);
46-
},
47-
onSecondaryRateLimit: (retryAfter: number, options: Required<EndpointDefaults>) => {
48-
logger.warn(`GitHub rate limit: SecondaryRateLimit detected for request ${options.method} ${options.url}`);
41+
return getClient(cacheKey, async () => {
42+
const CustomOctokit = Octokit.plugin(throttling);
43+
const ocktokitOptions: OctokitOptions = {
44+
auth: token,
45+
};
46+
if (ghesApiUrl) {
47+
ocktokitOptions.baseUrl = ghesApiUrl;
48+
ocktokitOptions.previews = ['antiope'];
49+
}
50+
51+
return new CustomOctokit({
52+
...ocktokitOptions,
53+
userAgent: process.env.USER_AGENT || 'github-aws-runners',
54+
throttle: {
55+
onRateLimit: (retryAfter: number, options: Required<EndpointDefaults>) => {
56+
logger.warn(
57+
`GitHub rate limit: Request quota exhausted for request ${options.method} ${options.url}. Requested `,
58+
);
59+
},
60+
onSecondaryRateLimit: (retryAfter: number, options: Required<EndpointDefaults>) => {
61+
logger.warn(`GitHub rate limit: SecondaryRateLimit detected for request ${options.method} ${options.url}`);
62+
},
4963
},
50-
},
64+
});
5165
});
5266
}
5367

5468
export async function createGithubAppAuth(
5569
installationId: number | undefined,
5670
ghesApiUrl = '',
5771
): Promise<AppAuthentication> {
58-
const auth = await createAuth(installationId, ghesApiUrl);
59-
const appAuthOptions: AppAuthOptions = { type: 'app' };
60-
return auth(appAuthOptions);
72+
const cacheKey = createAuthCacheKey('app', installationId, ghesApiUrl);
73+
74+
return getAppAuthObject(cacheKey, async () => {
75+
const auth = await createAuth(installationId, ghesApiUrl);
76+
const appAuthOptions: AppAuthOptions = { type: 'app' };
77+
return auth(appAuthOptions);
78+
});
6179
}
6280

6381
export async function createGithubInstallationAuth(
6482
installationId: number | undefined,
6583
ghesApiUrl = '',
6684
): Promise<InstallationAccessTokenAuthentication> {
67-
const auth = await createAuth(installationId, ghesApiUrl);
68-
const installationAuthOptions: InstallationAuthOptions = { type: 'installation', installationId };
69-
return auth(installationAuthOptions);
85+
const cacheKey = createAuthCacheKey('installation', installationId, ghesApiUrl);
86+
87+
return getInstallationAuthObject(cacheKey, async () => {
88+
const auth = await createAuth(installationId, ghesApiUrl);
89+
const installationAuthOptions: InstallationAuthOptions = { type: 'installation', installationId };
90+
return auth(installationAuthOptions);
91+
});
7092
}
7193

7294
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(
95+
const configCacheKey = createAuthConfigCacheKey(ghesApiUrl);
96+
97+
const config = await getAuthConfig(configCacheKey, async (): Promise<GithubAppConfig> => {
98+
const appId = parseInt(await getParameter(process.env.PARAMETER_GITHUB_APP_ID_NAME));
99+
const privateKey = Buffer.from(
77100
await getParameter(process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME),
78101
'base64',
79102
// replace literal \n characters with new lines to allow the key to be stored as a
80103
// single line variable. This logic should match how the GitHub Terraform provider
81104
// processes private keys to retain compatibility between the projects
82105
)
83106
.toString()
84-
.replace('/[\\n]/g', String.fromCharCode(10)),
107+
.replace('/[\\n]/g', String.fromCharCode(10));
108+
109+
return {
110+
appId,
111+
privateKey,
112+
};
113+
});
114+
115+
let authOptions: StrategyOptions = {
116+
appId: config.appId,
117+
privateKey: config.privateKey,
85118
};
86119
if (installationId) authOptions = { ...authOptions, installationId };
87120

88121
logger.debug(`GHES API URL: ${ghesApiUrl}`);
89122
if (ghesApiUrl) {
90123
authOptions.request = request.defaults({
91124
baseUrl: ghesApiUrl,
92-
});
125+
}) as RequestInterface;
93126
}
94127
return createAppAuth(authOptions);
95128
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { AppAuthentication, InstallationAccessTokenAuthentication } from '@octokit/auth-app';
2+
import { Octokit } from '@octokit/rest';
3+
import type { GithubAppConfig } from './types';
4+
5+
const clients = new Map<string, Octokit>();
6+
const appAuthObjects = new Map<string, AppAuthentication>();
7+
const installationAuthObjects = new Map<string, InstallationAccessTokenAuthentication>();
8+
const authConfigs = new Map<string, GithubAppConfig>();
9+
10+
export function createTokenCacheKey(token: string, ghesApiUrl: string = '') {
11+
return `octokit-${token}-${ghesApiUrl}`;
12+
}
13+
14+
export function createAuthCacheKey(type: 'app' | 'installation', installationId?: number, ghesApiUrl: string = '') {
15+
const id = installationId || 'none';
16+
return `${type}-auth-${id}-${ghesApiUrl}`;
17+
}
18+
19+
export function createAuthConfigCacheKey(ghesApiUrl: string = '') {
20+
return `auth-config-${ghesApiUrl}`;
21+
}
22+
23+
export async function getClient(key: string, creator: () => Promise<Octokit>): Promise<Octokit> {
24+
if (clients.has(key)) {
25+
return clients.get(key)!;
26+
}
27+
28+
const client = await creator();
29+
clients.set(key, client);
30+
return client;
31+
}
32+
33+
export async function getAppAuthObject(
34+
key: string,
35+
creator: () => Promise<AppAuthentication>,
36+
): Promise<AppAuthentication> {
37+
if (appAuthObjects.has(key)) {
38+
return appAuthObjects.get(key)!;
39+
}
40+
41+
const authObj = await creator();
42+
appAuthObjects.set(key, authObj);
43+
return authObj;
44+
}
45+
46+
export async function getInstallationAuthObject(
47+
key: string,
48+
creator: () => Promise<InstallationAccessTokenAuthentication>,
49+
): Promise<InstallationAccessTokenAuthentication> {
50+
if (installationAuthObjects.has(key)) {
51+
return installationAuthObjects.get(key)!;
52+
}
53+
54+
const authObj = await creator();
55+
installationAuthObjects.set(key, authObj);
56+
return authObj;
57+
}
58+
59+
export async function getAuthConfig(key: string, creator: () => Promise<GithubAppConfig>) {
60+
if (authConfigs.has(key)) {
61+
return authConfigs.get(key)!;
62+
}
63+
64+
const config = await creator();
65+
authConfigs.set(key, config);
66+
return config;
67+
}
68+
69+
export function reset() {
70+
clients.clear();
71+
appAuthObjects.clear();
72+
installationAuthObjects.clear();
73+
authConfigs.clear();
74+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './cache';
2+
export * from './types';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface GithubAppConfig {
2+
appId: number;
3+
privateKey: string;
4+
installationId?: number;
5+
}

lambdas/functions/control-plane/src/local.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const sqsEvent = {
77
{
88
messageId: 'e8d74d08-644e-42ca-bf82-a67daa6c4dad',
99
receiptHandle:
10-
// eslint-disable-next-line max-len
1110
'AQEBCpLYzDEKq4aKSJyFQCkJduSKZef8SJVOperbYyNhXqqnpFG5k74WygVAJ4O0+9nybRyeOFThvITOaS21/jeHiI5fgaM9YKuI0oGYeWCIzPQsluW5CMDmtvqv1aA8sXQ5n2x0L9MJkzgdIHTC3YWBFLQ2AxSveOyIHwW+cHLIFCAcZlOaaf0YtaLfGHGkAC4IfycmaijV8NSlzYgDuxrC9sIsWJ0bSvk5iT4ru/R4+0cjm7qZtGlc04k9xk5Fu6A+wRxMaIyiFRY+Ya19ykcevQldidmEjEWvN6CRToLgclk=',
1211
body: {
1312
repositoryName: 'self-hosted',

modules/multi-runner/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ module "multi-runner" {
171171
| <a name="input_runners_ssm_housekeeper"></a> [runners\_ssm\_housekeeper](#input\_runners\_ssm\_housekeeper) | Configuration for the SSM housekeeper lambda. This lambda deletes token / JIT config from SSM.<br/><br/> `schedule_expression`: is used to configure the schedule for the lambda.<br/> `enabled`: enable or disable the lambda trigger via the EventBridge.<br/> `lambda_memory_size`: lambda memory size limit.<br/> `lambda_timeout`: timeout for the lambda in seconds.<br/> `config`: configuration for the lambda function. Token path will be read by default from the module. | <pre>object({<br/> schedule_expression = optional(string, "rate(1 day)")<br/> enabled = optional(bool, true)<br/> lambda_memory_size = optional(number, 512)<br/> lambda_timeout = optional(number, 60)<br/> config = object({<br/> tokenPath = optional(string)<br/> minimumDaysOld = optional(number, 1)<br/> dryRun = optional(bool, false)<br/> })<br/> })</pre> | <pre>{<br/> "config": {}<br/>}</pre> | no |
172172
| <a name="input_scale_down_lambda_memory_size"></a> [scale\_down\_lambda\_memory\_size](#input\_scale\_down\_lambda\_memory\_size) | Memory size limit in MB for scale down. | `number` | `512` | no |
173173
| <a name="input_scale_up_lambda_memory_size"></a> [scale\_up\_lambda\_memory\_size](#input\_scale\_up\_lambda\_memory\_size) | Memory size limit in MB for scale\_up lambda. | `number` | `512` | no |
174-
| <a name="input_ssm_paths"></a> [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secreets. | <pre>object({<br/> root = optional(string, "github-action-runners")<br/> app = optional(string, "app")<br/> runners = optional(string, "runners")<br/> webhook = optional(string, "webhook")<br/> })</pre> | `{}` | no |
174+
| <a name="input_ssm_paths"></a> [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. | <pre>object({<br/> root = optional(string, "github-action-runners")<br/> app = optional(string, "app")<br/> runners = optional(string, "runners")<br/> webhook = optional(string, "webhook")<br/> })</pre> | `{}` | no |
175175
| <a name="input_state_event_rule_binaries_syncer"></a> [state\_event\_rule\_binaries\_syncer](#input\_state\_event\_rule\_binaries\_syncer) | Option to disable EventBridge Lambda trigger for the binary syncer, useful to stop automatic updates of binary distribution | `string` | `"ENABLED"` | no |
176176
| <a name="input_subnet_ids"></a> [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes |
177177
| <a name="input_syncer_lambda_s3_key"></a> [syncer\_lambda\_s3\_key](#input\_syncer\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no |

0 commit comments

Comments
 (0)