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 | undefined; let coreModulePromise: Promise | undefined; let authAppModulePromise: Promise | undefined; export function loadOctokitRest(): Promise { return (restModulePromise ??= import('@octokit/rest') as Promise); } export function loadOctokitCore(): Promise { return (coreModulePromise ??= import('@octokit/core') as Promise); } export function loadOctokitAuthApp(): Promise { return (authAppModulePromise ??= import('@octokit/auth-app') as Promise); } // ---- 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(); 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>>(); 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 { 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; } }