Skip to content

Commit 14bd0be

Browse files
committed
feat: introduce github caching
1 parent c29838c commit 14bd0be

File tree

6 files changed

+130
-36
lines changed

6 files changed

+130
-36
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: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,74 +22,106 @@ 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+
getAuthObject,
28+
getAuthConfig,
29+
createTokenCacheKey,
30+
createAuthCacheKey,
31+
createAuthConfigCacheKey,
32+
} from './cache';
33+
import type { GithubAppConfig } from './types';
2534

2635
const logger = createChildLogger('gh-auth');
2736

2837
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-
}
38+
const cacheKey = createTokenCacheKey(token, ghesApiUrl);
3739

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

5467
export async function createGithubAppAuth(
5568
installationId: number | undefined,
5669
ghesApiUrl = '',
5770
): Promise<AppAuthentication> {
58-
const auth = await createAuth(installationId, ghesApiUrl);
59-
const appAuthOptions: AppAuthOptions = { type: 'app' };
60-
return auth(appAuthOptions);
71+
const cacheKey = createAuthCacheKey('app', installationId, ghesApiUrl);
72+
73+
return getAuthObject<AppAuthentication>(cacheKey, async () => {
74+
const auth = await createAuth(installationId, ghesApiUrl);
75+
const appAuthOptions: AppAuthOptions = { type: 'app' };
76+
return auth(appAuthOptions);
77+
});
6178
}
6279

6380
export async function createGithubInstallationAuth(
6481
installationId: number | undefined,
6582
ghesApiUrl = '',
6683
): Promise<InstallationAccessTokenAuthentication> {
67-
const auth = await createAuth(installationId, ghesApiUrl);
68-
const installationAuthOptions: InstallationAuthOptions = { type: 'installation', installationId };
69-
return auth(installationAuthOptions);
84+
const cacheKey = createAuthCacheKey('installation', installationId, ghesApiUrl);
85+
86+
return getAuthObject<InstallationAccessTokenAuthentication>(cacheKey, async () => {
87+
const auth = await createAuth(installationId, ghesApiUrl);
88+
const installationAuthOptions: InstallationAuthOptions = { type: 'installation', installationId };
89+
return auth(installationAuthOptions);
90+
});
7091
}
7192

7293
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(
94+
const configCacheKey = createAuthConfigCacheKey(ghesApiUrl);
95+
96+
const config = await getAuthConfig(configCacheKey, async (): Promise<GithubAppConfig> => {
97+
const appId = parseInt(await getParameter(process.env.PARAMETER_GITHUB_APP_ID_NAME));
98+
const privateKey = Buffer.from(
7799
await getParameter(process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME),
78100
'base64',
79101
// replace literal \n characters with new lines to allow the key to be stored as a
80102
// single line variable. This logic should match how the GitHub Terraform provider
81103
// processes private keys to retain compatibility between the projects
82104
)
83105
.toString()
84-
.replace('/[\\n]/g', String.fromCharCode(10)),
106+
.replace('/[\\n]/g', String.fromCharCode(10));
107+
108+
return {
109+
appId,
110+
privateKey,
111+
};
112+
});
113+
114+
let authOptions: StrategyOptions = {
115+
appId: config.appId,
116+
privateKey: config.privateKey,
85117
};
86118
if (installationId) authOptions = { ...authOptions, installationId };
87119

88120
logger.debug(`GHES API URL: ${ghesApiUrl}`);
89121
if (ghesApiUrl) {
90122
authOptions.request = request.defaults({
91123
baseUrl: ghesApiUrl,
92-
});
124+
}) as RequestInterface;
93125
}
94126
return createAppAuth(authOptions);
95127
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Octokit } from '@octokit/rest';
2+
import type { GithubAppConfig } from './types';
3+
4+
const clients = new Map<string, Octokit>();
5+
const authObjects = new Map<string, any>();
6+
const authConfigs = new Map<string, GithubAppConfig>();
7+
8+
export function createTokenCacheKey(token: string, ghesApiUrl: string = '') {
9+
return `octokit-${token}-${ghesApiUrl}`;
10+
}
11+
12+
export function createAuthCacheKey(type: 'app' | 'installation', installationId?: number, ghesApiUrl: string = '') {
13+
const id = installationId || 'none';
14+
return `${type}-auth-${id}-${ghesApiUrl}`;
15+
}
16+
17+
export function createAuthConfigCacheKey(ghesApiUrl: string = '') {
18+
return `auth-config-${ghesApiUrl}`;
19+
}
20+
21+
export async function getClient(key: string, creator: () => Promise<Octokit>): Promise<Octokit> {
22+
if (clients.has(key)) {
23+
return clients.get(key)!;
24+
}
25+
26+
const client = await creator();
27+
clients.set(key, client);
28+
return client;
29+
}
30+
31+
export async function getAuthObject<T>(key: string, creator: () => Promise<T>) {
32+
if (authObjects.has(key)) {
33+
return authObjects.get(key) as T;
34+
}
35+
36+
const authObj = await creator();
37+
authObjects.set(key, authObj);
38+
return authObj;
39+
}
40+
41+
export async function getAuthConfig(key: string, creator: () => Promise<GithubAppConfig>) {
42+
if (authConfigs.has(key)) {
43+
return authConfigs.get(key)!;
44+
}
45+
46+
const config = await creator();
47+
authConfigs.set(key, config);
48+
return config;
49+
}
50+
51+
export function reset() {
52+
clients.clear();
53+
authObjects.clear();
54+
authConfigs.clear();
55+
}
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+
}

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)