src/lambda-github.ts
Rama main · réplica de lectura del repositorio original.
import { createHash } from 'crypto';
import type { Octokit as RestOctokit } from '@octokit/rest' with { 'resolution-mode': 'import' };
import { GITHUB_PRIVATE_KEY_PLACEHOLDER } from './lambda-common';
import { getSecretJsonValue, getSecretValue, RunnerConfigurationError } from './lambda-helpers';
// ---- Octokit ESM loader helpers (inlined) ----
// Octokit packages are ESM, but our Lambda assets are bundled into CJS.
// Using dynamic `import()` here lets esbuild include Octokit in the bundle.
type OctokitRestModule = typeof import('@octokit/rest', { with: { 'resolution-mode': 'import' } });
type OctokitCoreModule = typeof import('@octokit/core', { with: { 'resolution-mode': 'import' } });
type OctokitAuthAppModule = typeof import('@octokit/auth-app', { with: { 'resolution-mode': 'import' } });
let restModulePromise: Promise<OctokitRestModule> | undefined;
let coreModulePromise: Promise<OctokitCoreModule> | undefined;
let authAppModulePromise: Promise<OctokitAuthAppModule> | undefined;
export function loadOctokitRest(): Promise<OctokitRestModule> {
return (restModulePromise ??= import('@octokit/rest') as Promise<OctokitRestModule>);
}
export function loadOctokitCore(): Promise<OctokitCoreModule> {
return (coreModulePromise ??= import('@octokit/core') as Promise<OctokitCoreModule>);
}
export function loadOctokitAuthApp(): Promise<OctokitAuthAppModule> {
return (authAppModulePromise ??= import('@octokit/auth-app') as Promise<OctokitAuthAppModule>);
}
// ---- Other helpers ----
export function baseUrlFromDomain(domain: string): string {
if (domain == 'github.com') {
return 'https://api.github.com';
}
return `https://${domain}/api/v3`;
}
export type RunnerLevel = 'repo' | 'org' | undefined; // undefined is for backwards compatibility and should be treated as 'repo'
export interface GitHubSecrets {
domain: string;
appId: number;
personalAuthToken: string;
runnerLevel: RunnerLevel;
}
const octokitCache = new Map<string, RestOctokit>();
const SETUP_DOCS = 'See https://github.com/CloudSnorkel/cdk-github-runners/blob/main/SETUP_GITHUB.md';
/**
* Confirm we have what we need for app authentication. Called only when there is no personal access token.
*
* @internal
*/
export function checkAppAuth(githubSecrets: GitHubSecrets, installationId?: number) {
if (!githubSecrets.appId) {
throw new RunnerConfigurationError('GitHub authentication has not been set up. The GitHub secret has neither a personal access token ' +
`(personalAuthToken) nor an app id (appId). Run the setup wizard linked in the stack outputs, or set the secret manually. ${SETUP_DOCS}`);
}
if (installationId === undefined || installationId <= 0) {
throw new RunnerConfigurationError('Installation ID is required for app authentication. ' +
'This error can happen if you create the webhook yourself for GitHub app authentication instead of using the app webhook.');
}
}
/**
* Confirm the private key secret was actually filled in after deployment.
*
* @internal
*/
export function checkPrivateKey(privateKey: string) {
if (privateKey.trim() === GITHUB_PRIVATE_KEY_PLACEHOLDER) {
throw new RunnerConfigurationError('GitHub app private key has not been set. The private key secret still has the placeholder we deploy ' +
`with. Run the setup wizard linked in the stack outputs, or put the app private key in the secret manually. ${SETUP_DOCS}`);
}
}
export async function getOctokit(installationId?: number): Promise<{ octokit: RestOctokit; githubSecrets: GitHubSecrets }> {
if (!process.env.GITHUB_SECRET_ARN || !process.env.GITHUB_PRIVATE_KEY_SECRET_ARN) {
throw new Error('Missing environment variables');
}
const [{ Octokit }, { createAppAuth }] = await Promise.all([
loadOctokitRest(),
loadOctokitAuthApp(),
]);
const githubSecrets: GitHubSecrets = await getSecretJsonValue(process.env.GITHUB_SECRET_ARN);
// Create cache key from installation ID and secrets (hash to avoid exposing sensitive data by accident)
const cacheKey = createHash('sha256').update(`${installationId || 'no-install'}-${githubSecrets.domain}-${githubSecrets.appId}-${githubSecrets.personalAuthToken}`).digest('hex');
const cached = octokitCache.get(cacheKey);
if (cached) {
try {
// Test if the cached octokit is still valid
await cached.rest.meta.getOctocat();
console.log({
notice: 'Using cached octokit',
});
return {
octokit: cached,
githubSecrets,
};
} catch (e) {
console.log({
notice: 'Octokit cache is invalid',
error: e,
});
octokitCache.delete(cacheKey);
}
}
const baseUrl = baseUrlFromDomain(githubSecrets.domain);
let token;
if (githubSecrets.personalAuthToken) {
token = githubSecrets.personalAuthToken;
} else {
checkAppAuth(githubSecrets, installationId);
const privateKey = await getSecretValue(process.env.GITHUB_PRIVATE_KEY_SECRET_ARN);
checkPrivateKey(privateKey);
const appOctokit = new Octokit({
baseUrl,
authStrategy: createAppAuth,
auth: {
appId: githubSecrets.appId,
privateKey: privateKey,
},
});
token = (await appOctokit.auth({
type: 'installation',
installationId: installationId,
}) as any).token;
}
const octokit = new Octokit({
baseUrl,
auth: token,
});
// Store in cache
octokitCache.set(cacheKey, octokit);
return {
octokit,
githubSecrets,
};
}
/**
* `getOctokit` reads secrets manager and calls GitHub every time. Handlers that work on many runners at once should get one of these per
* invocation and use it instead. Tokens expire, so don't keep it any longer than the length of one Lambda call.
*
* @internal
*/
export function cachedGetOctokit(): typeof getOctokit {
const cache = new Map<number | undefined, Awaited<ReturnType<typeof getOctokit>>>();
return async (installationId?: number) => {
let cached = cache.get(installationId);
if (!cached) {
cached = await getOctokit(installationId);
cache.set(installationId, cached);
}
return cached;
};
}
// This function is used to get the Octokit instance for the app itself, not for a specific installation.
// With PAT authentication, it returns undefined.
export async function getAppOctokit(): Promise<RestOctokit | undefined> {
if (!process.env.GITHUB_SECRET_ARN || !process.env.GITHUB_PRIVATE_KEY_SECRET_ARN) {
throw new Error('Missing environment variables');
}
const [{ Octokit }, { createAppAuth }] = await Promise.all([
loadOctokitRest(),
loadOctokitAuthApp(),
]);
const githubSecrets: GitHubSecrets = await getSecretJsonValue(process.env.GITHUB_SECRET_ARN);
const baseUrl = baseUrlFromDomain(githubSecrets.domain);
if (githubSecrets.personalAuthToken || !githubSecrets.appId) {
return undefined;
}
const privateKey = await getSecretValue(process.env.GITHUB_PRIVATE_KEY_SECRET_ARN);
checkPrivateKey(privateKey);
return new Octokit({
baseUrl,
authStrategy: createAppAuth,
auth: {
appId: githubSecrets.appId,
privateKey: privateKey,
},
});
}
export async function getRunner(octokit: RestOctokit, runnerLevel: RunnerLevel, owner: string, repo: string, name: string) {
let page = 1;
while (true) {
let runners;
if ((runnerLevel ?? 'repo') === 'repo') {
runners = await octokit.rest.actions.listSelfHostedRunnersForRepo({
name: name,
page: page,
owner: owner,
repo: repo,
});
} else {
runners = await octokit.rest.actions.listSelfHostedRunnersForOrg({
name: name,
page: page,
org: owner,
});
}
if (runners.data.runners.length == 0) {
return;
}
for (const runner of runners.data.runners) {
// we filter by name in the API call, but still double-check here
// this is for backward compatibility with old GHES instances that may not support the name filter
if (runner.name == name) {
return runner;
}
}
page++;
}
}
export async function deleteRunner(octokit: RestOctokit, runnerLevel: RunnerLevel, owner: string, repo: string, runnerId: number) {
if ((runnerLevel ?? 'repo') === 'repo') {
await octokit.rest.actions.deleteSelfHostedRunnerFromRepo({
owner: owner,
repo: repo,
runner_id: runnerId,
});
} else {
await octokit.rest.actions.deleteSelfHostedRunnerFromOrg({
org: owner,
runner_id: runnerId,
});
}
}
export async function redeliver(octokit: RestOctokit, deliveryId: bigint) {
const response = await octokit.rest.apps.redeliverWebhookDelivery({
// waiting for new octokit -- https://github.com/octokit/request.js/issues/797#issuecomment-3953274583
delivery_id: deliveryId as unknown as number,
});
if (response.status !== 202) {
throw new Error(`Failed to redeliver webhook delivery with ID ${deliveryId}`);
}
console.log({
notice: 'Successfully redelivered webhook delivery',
deliveryId: String(deliveryId),
});
}
/**
* Did GitHub tell us it can't see this? Used to tell "we have no access" apart from a real failure, because the
* first is permanent and retrying it forever costs rate limit we need for starting runners.
*/
export function isNotFound(e: unknown): boolean {
return (e as { status?: number })?.status === 404;
}
/**
* Find installation id for our app. Normal code path gets this from the webhook payload, but we schedule these ourselves.
*
* If the repository cannot be found (e.g. the app was uninstalled), this will throw a 404 error. Use `isNotFound` to check for that case.
*
* @internal
*/
export async function resolveInstallationId(owner: string, repo: string) {
const appOctokit = await getAppOctokit();
if (!appOctokit) {
return undefined; // PAT authentication
}
if (repo) {
const { data } = await appOctokit.rest.apps.getRepoInstallation({ owner, repo });
return data.id;
} else {
const { data } = await appOctokit.rest.apps.getOrgInstallation({ org: owner });
return data.id;
}
}