Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 27 additions & 14 deletions lambdas/functions/control-plane/src/github/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ 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 { createGitHubAppCredentialsStore, type GitHubAppCredential } from '@aws-github-runner/storage-providers';
import {
createCommonStorage,
type GitHubAppCredential,
type GitHubAppCredentialsStore,
} from '@aws-github-runner/storage-providers';
import { EndpointDefaults } from '@octokit/types';

type AppAuthOptions = { type: 'app' };
Expand Down Expand Up @@ -56,31 +60,37 @@ export function onSecondaryRateLimit(
let appCredentialsPromise: Promise<GitHubAppCredential[]> | null = null;

async function loadAppCredentials(): Promise<GitHubAppCredential[]> {
const credentials = await createGitHubAppCredentialsStore().get();
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): Promise<string> {
const credential = (await getAppCredentials())[appIndex];
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`);
}
Expand Down Expand Up @@ -117,21 +127,23 @@ 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 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 @@ -147,8 +159,9 @@ 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ vi.mock('../github/auth', () => ({
vi.mock('../scale-runners/github-runner', () => ({
createStartRunnerConfig: vi.fn(),
getGitHubEnterpriseApiUrl: vi.fn(),
validateSsmParameterStoreTags: vi.fn(),
}));

const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth);
Expand Down Expand Up @@ -49,6 +48,9 @@ beforeEach(() => {
vi.clearAllMocks();
process.env = { ...cleanEnv };
process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens';
process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config';
process.env.PARAMETER_GITHUB_APP_ID_NAME = 'github-app-id';
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = 'github-app-key';

mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' });
mockedInstallationAuth.mockResolvedValue({
Expand All @@ -63,7 +65,6 @@ beforeEach(() => {
});
mockedCreateClient.mockResolvedValue(githubClient);
vi.mocked(githubRunner.getGitHubEnterpriseApiUrl).mockReturnValue({ ghesApiUrl: '', ghesBaseUrl: '' });
vi.mocked(githubRunner.validateSsmParameterStoreTags).mockReturnValue([]);
vi.mocked(githubClient.apps.getOrgInstallation).mockResolvedValue({ data: { id: 2 } } as never);
vi.mocked(githubClient.paginate).mockResolvedValue([]);
});
Expand Down
6 changes: 4 additions & 2 deletions lambdas/functions/control-plane/src/pool/pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ vi.mock('../scale-runners/github-runner', () => ({
ghesApiUrl: '',
ghesBaseUrl: '',
}),
validateSsmParameterStoreTags: vi.fn().mockReturnValue([]),
}));

const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth);
Expand Down Expand Up @@ -93,6 +92,9 @@ beforeEach(() => {
process.env.RUNNERS_MAXIMUM_COUNT = '-1';
process.env.ENVIRONMENT = 'unit-test-environment';
process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens';
process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config';
process.env.PARAMETER_GITHUB_APP_ID_NAME = 'github-app-id';
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = 'github-app-key';
process.env.RUNNER_OWNER = ORG;

githubClient.paginate.mockResolvedValue(githubRunnersRegistered);
Expand Down Expand Up @@ -325,7 +327,7 @@ describe('pool adjustment', () => {

await adjust({ poolSize: 3 });

expect(mockedInstallationAuth).toHaveBeenCalledWith(expect.any(Number), expect.any(String), 1);
expect(mockedInstallationAuth).toHaveBeenCalledWith(expect.any(Number), expect.any(String), 1, expect.anything());
});

it('looks up installationId using the selected app JWT', async () => {
Expand Down
28 changes: 15 additions & 13 deletions lambdas/functions/control-plane/src/pool/pool.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Octokit } from '@octokit/rest';
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types';
import { createStorageProviders, type StorageProviders } from '@aws-github-runner/storage-providers';
import yn from 'yn';

import {
Expand All @@ -10,7 +11,7 @@ import {
getStoredInstallationId,
} from '../github/auth';
import { controlPlaneProviderRegistry } from '../control-plane-providers';
import { getGitHubEnterpriseApiUrl, validateSsmParameterStoreTags } from '../scale-runners/github-runner';
import { getGitHubEnterpriseApiUrl } from '../scale-runners/github-runner';
import type { RunnerStatus } from './pool-provider';

const logger = createChildLogger('pool');
Expand All @@ -21,6 +22,7 @@ export interface PoolEvent {
}

export async function adjust(event: PoolEvent): Promise<void> {
const storage = createStorageProviders();
const computeProviderType = resolveComputeProviderType(event.type);
const computeProvider = {
...controlPlaneProviderRegistry.capability(computeProviderType, 'pool')(),
Expand All @@ -31,15 +33,10 @@ export async function adjust(event: PoolEvent): Promise<void> {
const runnerGroup = process.env.RUNNER_GROUP_NAME || '';
const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX || '';
const environment = process.env.ENVIRONMENT;
const ssmConfigPath = process.env.SSM_CONFIG_PATH || '';
const ephemeral = yn(process.env.ENABLE_EPHEMERAL_RUNNERS, { default: false });
const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeral });
const disableAutoUpdate = yn(process.env.DISABLE_RUNNER_AUTOUPDATE, { default: false });
const runnerOwner = process.env.RUNNER_OWNER;
const ssmParameterStoreTags: { Key: string; Value: string }[] =
process.env.SSM_PARAMETER_STORE_TAGS && process.env.SSM_PARAMETER_STORE_TAGS.trim() !== ''
? validateSsmParameterStoreTags(process.env.SSM_PARAMETER_STORE_TAGS)
: [];
// -1 disables the maximum check, matching the scale-up lambda's semantics. Defaults to unlimited
// when unset so the pool keeps its previous behavior on stacks that do not provide the variable.
const maximumRunners = parseInt(process.env.RUNNERS_MAXIMUM_COUNT || '-1');
Expand All @@ -49,11 +46,11 @@ export async function adjust(event: PoolEvent): Promise<void> {

// Select one GitHub App for this entire invocation so every API call draws
// from the same rate-limit bucket.
const ghAppAuth = await createGithubAppAuth(undefined, ghesApiUrl);
const ghAppAuth = await createGithubAppAuth(undefined, ghesApiUrl, undefined, storage.githubAppCredentials);
const appIdx = ghAppAuth.appIndex;

const installationId = await getInstallationId(ghAppAuth.token, ghesApiUrl, runnerOwner, appIdx);
const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl, appIdx);
const installationId = await getInstallationId(ghAppAuth.token, ghesApiUrl, runnerOwner, appIdx, storage);
const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl, appIdx, storage.githubAppCredentials);
const githubInstallationClient = await createOctokitClient(ghAuth.token, ghesApiUrl);

// Get statuses of runners registered in GitHub
Expand Down Expand Up @@ -102,20 +99,25 @@ export async function adjust(event: PoolEvent): Promise<void> {
runnerNamePrefix,
runnerType: 'Org',
disableAutoUpdate: disableAutoUpdate,
ssmConfigPath,
ssmParameterStoreTags,
},
numberOfRunners: topUp,
githubInstallationClient,
storage,
});
} else {
logger.info(`Pool will not be topped up. Found ${numberOfRunnersInPool} managed idle runners.`);
}
}

async function getInstallationId(appToken: string, ghesApiUrl: string, org: string, appIndex: number): Promise<number> {
async function getInstallationId(
appToken: string,
ghesApiUrl: string,
org: string,
appIndex: number,
storage?: StorageProviders,
): Promise<number> {
// Use the pre-configured installation ID when available (avoids an API call).
const storedId = await getStoredInstallationId(appIndex);
const storedId = await getStoredInstallationId(appIndex, storage?.githubAppCredentials);
if (storedId !== undefined) return storedId;

const githubClient = await createOctokitClient(appToken, ghesApiUrl);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
import {
createRunnerConfigStore,
createRunnerGroupCacheStore,
createStorageProviders,
type RunnerConfigMetadata,
type RunnerConfigStore,
type GitHubAppCredentialsStore,
type RunnerGroupCacheStore,
} from '@aws-github-runner/storage-providers';
import { Octokit } from '@octokit/rest';
Expand All @@ -20,6 +20,7 @@ export interface GitHubRunnerMetadata {
}

export interface StartRunnerConfigOptions {
runnerConfigStore?: RunnerConfigStore;
runnerGroupCacheStore?: RunnerGroupCacheStore;
getRunnerConfigMetadata?: (runnerId: string) => RunnerConfigMetadata[];
onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise<void>;
Expand Down Expand Up @@ -58,37 +59,6 @@ function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}

export function validateSsmParameterStoreTags(tagsJson: string): { Key: string; Value: string }[] {
try {
const tags = JSON.parse(tagsJson);

if (!Array.isArray(tags)) {
throw new Error('Tags must be an array');
}

if (tags.length === 0) {
return [];
}

tags.forEach((tag, index) => {
if (typeof tag !== 'object' || tag === null) {
throw new Error(`Tag at index ${index} must be an object`);
}
if (!tag.Key || typeof tag.Key !== 'string' || tag.Key.trim() === '') {
throw new Error(`Tag at index ${index} has missing or invalid 'Key' property`);
}
if (!Object.prototype.hasOwnProperty.call(tag, 'Value') || typeof tag.Value !== 'string') {
throw new Error(`Tag at index ${index} has missing or invalid 'Value' property`);
}
});

return tags;
} catch (err) {
logger.error('Invalid SSM_PARAMETER_STORE_TAGS format', { error: err });
throw new Error(`Failed to parse SSM_PARAMETER_STORE_TAGS: ${(err as Error).message}`);
}
}

async function getGithubRunnerRegistrationToken(githubRunnerConfig: CreateGitHubRunnerConfig, ghClient: Octokit) {
const registrationToken =
githubRunnerConfig.runnerType === 'Org'
Expand Down Expand Up @@ -137,10 +107,11 @@ export async function getInstallationId(
enableOrgLevel: boolean,
payload: ActionRequestMessage,
appIndex?: number,
credentialsStore?: GitHubAppCredentialsStore,
): Promise<number> {
// Use the pre-configured installation ID when available (avoids an API call).
if (appIndex !== undefined) {
const storedId = await getStoredInstallationId(appIndex);
const storedId = await getStoredInstallationId(appIndex, credentialsStore);
if (storedId !== undefined) return storedId;
}

Expand Down Expand Up @@ -194,7 +165,7 @@ export async function getRunnerGroupId(
// if the runnerType is Repo, then runnerGroupId is default to 1
let runnerGroupId: number | undefined = 1;
if (githubRunnerConfig.runnerType === 'Org' && githubRunnerConfig.runnerGroup !== undefined) {
const cacheStore = runnerGroupCacheStore ?? createRunnerGroupCacheStore();
const cacheStore = runnerGroupCacheStore ?? createStorageProviders().runnerGroupCache;
const runnerGroup = await cacheStore.get(githubRunnerConfig.runnerGroup);
if (runnerGroup === undefined) {
// get runner group id from GitHub
Expand Down Expand Up @@ -236,7 +207,7 @@ export async function createStartRunnerConfig(
ghClient: Octokit,
options: StartRunnerConfigOptions = {},
): Promise<string[]> {
const runnerConfigStore = createRunnerConfigStore();
const runnerConfigStore = options.runnerConfigStore ?? createStorageProviders().runnerConfig;
if (githubRunnerConfig.enableJitConfig && githubRunnerConfig.ephemeral) {
return await createJitConfig(githubRunnerConfig, runnerIds, ghClient, runnerConfigStore, options);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ beforeEach(() => {
vi.clearAllMocks();
process.env = { ...cleanEnv };
process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens';
process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config';
process.env.PARAMETER_GITHUB_APP_ID_NAME = 'github-app-id';
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = 'github-app-key';

mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' });
mockedInstallationAuth.mockResolvedValue({
Expand Down
Loading