Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
16596c3
refactor(storage): separate runner config housekeeping
edersonbrilhante Sep 4, 2026
b52e02b
refactor(storage): extract GitHub App credentials store
edersonbrilhante Sep 4, 2026
4156e30
fix(auth): update credentials store test expectations
edersonbrilhante Sep 4, 2026
78f8ea0
fix(format): format GitHub App storage changes
edersonbrilhante Sep 4, 2026
9e9a564
fix(storage): preserve app installation ID slots
edersonbrilhante Sep 4, 2026
4b2e53c
feat(storage): add runner config consumer
edersonbrilhante Sep 4, 2026
968a676
fix(storage): simplify consumer environment export
edersonbrilhante Sep 4, 2026
97f1d50
fix(storage): format runner config consumer
edersonbrilhante Sep 4, 2026
81da129
fix(storage): remove duplicate consumer config export
edersonbrilhante Sep 4, 2026
2c9a843
refactor(storage): compose runner config storage providers
edersonbrilhante Sep 4, 2026
0cb4ea4
fix(storage): format provider selection changes
edersonbrilhante Sep 4, 2026
2010bf2
fix(control-plane): align storage provider test fixtures
edersonbrilhante Sep 4, 2026
5149453
test(compute-providers): cover storage capability argument
edersonbrilhante Sep 4, 2026
14426b4
fix(auth): update credentials store test expectations
edersonbrilhante Sep 4, 2026
f2aa91f
feat(compute-providers): add MicroVM API foundations
edersonbrilhante Aug 6, 2026
ba58d2f
feat(compute-providers): add MicroVM control-plane provider
edersonbrilhante Aug 6, 2026
2150fe0
feat(compute-providers): add MicroVM webhook routing
edersonbrilhante Aug 6, 2026
18ba5ac
docs(compute-providers): document Lambda MicroVM provider
edersonbrilhante Aug 6, 2026
2f9afd5
fix(compute-providers): replace unsupported MicroVM tags
edersonbrilhante Aug 19, 2026
7d8016e
fix(compute-providers): make metadata cleanup idempotent
edersonbrilhante Aug 19, 2026
b88827a
fix(compute-providers): remove MicroVM duration label
edersonbrilhante Aug 19, 2026
096e386
fix(compute-providers): fix MicroVM lifetime at eight hours
edersonbrilhante Aug 20, 2026
5293130
feat(microvm): tag runner metadata
edersonbrilhante Aug 21, 2026
108a06e
feat(microvm): add runner config ARN to hook payload
edersonbrilhante Aug 21, 2026
ec70433
fix(microvm): reuse runner configuration path
edersonbrilhante Aug 21, 2026
9e01f42
feat(microvm): extend runner lifecycle metadata
edersonbrilhante Aug 21, 2026
95dccb9
fix(deps): align Lambda lockfile after rebase
edersonbrilhante Sep 2, 2026
f92da36
fix(scale-runners): log JIT setup after provider callback
edersonbrilhante Sep 3, 2026
9a489c5
fix(scale-runners): restore provider callback ordering
edersonbrilhante Sep 3, 2026
5b161ed
revert(scale-runners): restore JIT callback ordering
edersonbrilhante Sep 3, 2026
3959d89
refactor(tests): keep MicroVM coverage in provider layer
edersonbrilhante Sep 3, 2026
817f8ef
fix(microvm): use shared runner source type
edersonbrilhante Sep 3, 2026
33bc762
fix(scale-runners): preserve existing JIT config ordering
edersonbrilhante Sep 3, 2026
b45dbb0
fix(microvm): read SSM settings from environment
edersonbrilhante Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 58 additions & 104 deletions lambdas/functions/control-plane/src/github/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { createAppAuth, type AppAuthentication, type InstallationAccessTokenAuthentication } from '@octokit/auth-app';
import type { OctokitOptions, Octokit as CoreOctokit } from '@octokit/core';
import type { RequestInterface } from '@octokit/types';
import { createSign, randomUUID } from 'node:crypto';
import { request } from '@octokit/request';
import { Octokit } from '@octokit/rest';
import { retry } from '@octokit/plugin-retry';
import { throttling } from '@octokit/plugin-throttling';
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
import {
createCommonStorage,
type GitHubAppCredential,
type GitHubAppCredentialsStore,
} from '@aws-github-runner/storage-providers';
import { EndpointDefaults } from '@octokit/types';

// Define types that are not directly exported
type AppAuthOptions = { type: 'app' };
type InstallationAuthOptions = { type: 'installation'; installationId?: number };
// Use a more generalized AuthInterface to match what createAppAuth returns
type AuthInterface = {
(options: AppAuthOptions): Promise<AppAuthentication>;
(options: InstallationAuthOptions): Promise<InstallationAccessTokenAuthentication>;
Expand All @@ -16,33 +26,14 @@ type StrategyOptions = {
installationId?: number;
request?: RequestInterface;
};
import { createSign, randomUUID } from 'node:crypto';
import { request } from '@octokit/request';
import { Octokit } from '@octokit/rest';
import { retry } from '@octokit/plugin-retry';
import { throttling } from '@octokit/plugin-throttling';
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
import { getParameters } from '@aws-github-runner/aws-ssm-util';
import { EndpointDefaults } from '@octokit/types';

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

// Retry caps for the throttling plugin. Returning `true` from a limit handler tells
// the plugin to retry after the interval GitHub asked for; returning `false` gives up.
// Primary rate limits reset on a fixed schedule, so a couple of retries is worthwhile.
// Secondary rate limits are abuse-detection signals — retry once, then back off and
// let the message return to the queue rather than pushing harder.
const MAX_RATE_LIMIT_RETRIES = 2;
const MAX_SECONDARY_RATE_LIMIT_RETRIES = 1;

// Exported for tests: the plugin only surfaces these via the client constructor,
// so there is no other seam to assert the retry cap against.
export function onRateLimit(
retryAfter: number,
options: Required<EndpointDefaults>,
// The throttling plugin types this as @octokit/core's Octokit, not the wider
// @octokit/rest one imported above; matching it keeps the handler assignable to
// the plugin's LimitHandler. Unused here regardless.
_octokit: CoreOctokit,
retryCount: number,
): boolean {
Expand All @@ -56,9 +47,6 @@ export function onRateLimit(
export function onSecondaryRateLimit(
retryAfter: number,
options: Required<EndpointDefaults>,
// The throttling plugin types this as @octokit/core's Octokit, not the wider
// @octokit/rest one imported above; matching it keeps the handler assignable to
// the plugin's LimitHandler. Unused here regardless.
_octokit: CoreOctokit,
retryCount: number,
): boolean {
Expand All @@ -69,125 +57,93 @@ export function onSecondaryRateLimit(
return retryCount < MAX_SECONDARY_RATE_LIMIT_RETRIES;
}

interface GitHubAppCredential {
appId: number;
privateKey: string;
installationId?: number;
}

let appCredentialsPromise: Promise<GitHubAppCredential[]> | null = null;

async function loadAppCredentials(): Promise<GitHubAppCredential[]> {
if (!process.env.PARAMETER_GITHUB_APP_ID_NAME) {
throw new Error('Environment variable PARAMETER_GITHUB_APP_ID_NAME is not set');
}
if (!process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME) {
throw new Error('Environment variable PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set');
}
const idParams = process.env.PARAMETER_GITHUB_APP_ID_NAME.split(':').filter(Boolean);
const keyParams = process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME.split(':').filter(Boolean);
const installationIdParams = (process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME || '').split(':');
if (idParams.length !== keyParams.length) {
throw new Error(`GitHub App parameter count mismatch: ${idParams.length} IDs vs ${keyParams.length} keys`);
}
// Batch fetch all SSM parameters in a single call to reduce API calls
const allParamNames = [...idParams, ...keyParams, ...installationIdParams.filter((p) => p.length > 0)];
const params = await getParameters(allParamNames);

const credentials: GitHubAppCredential[] = [];
for (let i = 0; i < idParams.length; i++) {
const appIdValue = params.get(idParams[i]);
if (!appIdValue) {
throw new Error(`Parameter ${idParams[i]} not found`);
}
const appId = parseInt(appIdValue, 10);
const privateKeyBase64 = params.get(keyParams[i]);
if (!privateKeyBase64) {
throw new Error(`Parameter ${keyParams[i]} not found`);
}
// replace literal \n characters with new lines to allow the key to be stored as a
// single line variable. This logic should match how the GitHub Terraform provider
// processes private keys to retain compatibility between the projects
const privateKey = Buffer.from(privateKeyBase64, 'base64').toString().replace(/\\n/g, '\n');
const installationIdParam = installationIdParams[i];
const installationIdValue =
installationIdParam && installationIdParam.length > 0 ? params.get(installationIdParam) : undefined;
const installationId = installationIdValue ? parseInt(installationIdValue, 10) : undefined;
credentials.push({ appId, privateKey, installationId });
}
const credentials = await createCommonStorage().githubAppCredentials.get();
logger.info(`Loaded ${credentials.length} GitHub App credential(s)`);
return credentials;
}

function getAppCredentials(): Promise<GitHubAppCredential[]> {
function getAppCredentials(credentialsStore?: GitHubAppCredentialsStore): Promise<GitHubAppCredential[]> {
if (credentialsStore) {
return credentialsStore.get();
}
if (!appCredentialsPromise) appCredentialsPromise = loadAppCredentials();
return appCredentialsPromise;
}

export async function getAppCount(): Promise<number> {
return (await getAppCredentials()).length;
export async function getAppCount(credentialsStore?: GitHubAppCredentialsStore): Promise<number> {
return (await getAppCredentials(credentialsStore)).length;
}

export function resetAppCredentialsCache(): void {
appCredentialsPromise = null;
}

export async function getStoredInstallationId(appIndex: number): Promise<number | undefined> {
const credentials = await getAppCredentials();
export async function getStoredInstallationId(
appIndex: number,
credentialsStore?: GitHubAppCredentialsStore,
): Promise<number | undefined> {
const credentials = await getAppCredentials(credentialsStore);
return credentials[appIndex]?.installationId;
}

export async function getAppId(appIndex = 0, credentialsStore?: GitHubAppCredentialsStore): Promise<string> {
const credential = (await getAppCredentials(credentialsStore))[appIndex];
if (!credential) {
throw new Error(`GitHub App credential at index ${appIndex} not found`);
}
return credential.appId.toString();
}

export async function createOctokitClient(token: string, ghesApiUrl = ''): Promise<Octokit> {
const CustomOctokit = Octokit.plugin(retry, throttling);
const ocktokitOptions: OctokitOptions = {
auth: token,
};
const octokitOptions: OctokitOptions = { auth: token };
if (ghesApiUrl) {
ocktokitOptions.baseUrl = ghesApiUrl;
ocktokitOptions.previews = ['antiope'];
octokitOptions.baseUrl = ghesApiUrl;
octokitOptions.previews = ['antiope'];
}

return new CustomOctokit({
...ocktokitOptions,
...octokitOptions,
userAgent: process.env.USER_AGENT || 'github-aws-runners',
retry: {
onRetry: (retryCount: number, error: Error, request: { method: string; url: string }) => {
onRetry: (retryCount: number, error: Error, retryRequest: { method: string; url: string }) => {
logger.warn('GitHub API request retry attempt', {
retryCount,
method: request.method,
url: request.url,
method: retryRequest.method,
url: retryRequest.url,
error: error.message,
status: (error as Error & { status?: number }).status,
});
},
},
throttle: {
onRateLimit,
onSecondaryRateLimit,
},
throttle: { onRateLimit, onSecondaryRateLimit },
});
}

export async function createGithubAppAuth(
installationId: number | undefined,
ghesApiUrl = '',
appIndex?: number,
credentialsStore?: GitHubAppCredentialsStore,
): Promise<AppAuthentication & { appIndex: number }> {
const credentials = await getAppCredentials();
const credentials = await getAppCredentials(credentialsStore);
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
const auth = await createAuth(installationId, ghesApiUrl, idx);
const result = await auth({ type: 'app' });
return { ...result, appIndex: idx };
const auth = await createAuth(installationId, ghesApiUrl, idx, credentialsStore);
return { ...(await auth({ type: 'app' })), appIndex: idx };
}

export async function createGithubInstallationAuth(
installationId: number | undefined,
ghesApiUrl = '',
appIndex?: number,
credentialsStore?: GitHubAppCredentialsStore,
): Promise<InstallationAccessTokenAuthentication> {
const credentials = await getAppCredentials();
const credentials = await getAppCredentials(credentialsStore);
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
const auth = await createAuth(installationId, ghesApiUrl, idx);
const auth = await createAuth(installationId, ghesApiUrl, idx, credentialsStore);
return auth({ type: 'installation', installationId });
}

Expand All @@ -203,17 +159,16 @@ async function createAuth(
installationId: number | undefined,
ghesApiUrl: string,
appIndex?: number,
credentialsStore?: GitHubAppCredentialsStore,
): Promise<AuthInterface> {
const credentials = await getAppCredentials();
const credentials = await getAppCredentials(credentialsStore);
const selected =
appIndex !== undefined ? credentials[appIndex] : credentials[Math.floor(Math.random() * credentials.length)];
if (!selected) {
throw new Error(`GitHub App credential at index ${appIndex ?? 0} not found`);
}

logger.debug(`Selected GitHub App ${selected.appId} for authentication`);

// Use a custom createJwt callback to include a jti (JWT ID) claim in every token.
// Without this, concurrent Lambda invocations generating JWTs within the same second
// produce byte-identical tokens (same iat, exp, iss), which GitHub rejects as duplicates.
// See: https://github.com/github-aws-runners/terraform-aws-github-runner/issues/5025
const createJwt = async (appId: string | number, timeDifference?: number) => {
const now = Math.floor(Date.now() / 1000) + (timeDifference ?? 0);
const iat = now - 30;
Expand All @@ -222,14 +177,13 @@ async function createAuth(
return { jwt, expiresAt: new Date(exp * 1000).toISOString() };
};

let authOptions: StrategyOptions = { appId: selected.appId, createJwt };
if (installationId) authOptions = { ...authOptions, installationId };

logger.debug(`GHES API URL: ${ghesApiUrl}`);
const authOptions: StrategyOptions = {
appId: selected.appId,
createJwt,
...(installationId ? { installationId } : {}),
};
if (ghesApiUrl) {
authOptions.request = request.defaults({
baseUrl: ghesApiUrl,
});
authOptions.request = request.defaults({ baseUrl: ghesApiUrl });
}
return createAppAuth(authOptions);
}
Loading