Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6e7d21e
refactor(storage): extract runner config store
edersonbrilhante Aug 18, 2026
f8e1207
refactor(storage): extract group cache and cleanup
edersonbrilhante Aug 18, 2026
ff9c091
refactor(storage): move local housekeeper harness
edersonbrilhante Aug 18, 2026
30c7948
refactor(storage): preserve SSM cleanup names
edersonbrilhante Aug 18, 2026
98f9db6
test(storage): decouple scale-up tests from SSM
edersonbrilhante Aug 18, 2026
ce6a33c
refactor(storage): extract GitHub App credentials
edersonbrilhante Aug 19, 2026
a3981eb
feat(storage): add SSM runner config consumer
edersonbrilhante Sep 3, 2026
a22c9b6
fix(control-plane): rename runner config housekeeper handler
edersonbrilhante Sep 3, 2026
0a593df
feat(compute-providers): add MicroVM API foundations
edersonbrilhante Aug 6, 2026
fe3ee67
feat(compute-providers): add MicroVM control-plane provider
edersonbrilhante Aug 6, 2026
0adfe55
feat(compute-providers): add MicroVM webhook routing
edersonbrilhante Aug 6, 2026
6753f7a
docs(compute-providers): document Lambda MicroVM provider
edersonbrilhante Aug 6, 2026
14b8af2
fix(compute-providers): replace unsupported MicroVM tags
edersonbrilhante Aug 19, 2026
89c16d6
fix(compute-providers): make metadata cleanup idempotent
edersonbrilhante Aug 19, 2026
cd432a3
fix(compute-providers): remove MicroVM duration label
edersonbrilhante Aug 19, 2026
5e9d0bd
fix(compute-providers): fix MicroVM lifetime at eight hours
edersonbrilhante Aug 20, 2026
ad9cbc4
feat(microvm): tag runner metadata
edersonbrilhante Aug 21, 2026
2259cc7
feat(microvm): add runner config ARN to hook payload
edersonbrilhante Aug 21, 2026
8045d8a
fix(microvm): reuse runner configuration path
edersonbrilhante Aug 21, 2026
0c6d3a4
feat(microvm): extend runner lifecycle metadata
edersonbrilhante Aug 21, 2026
dad9629
fix(deps): align Lambda lockfile after rebase
edersonbrilhante Sep 2, 2026
dc07023
fix(scale-runners): log JIT setup after provider callback
edersonbrilhante Sep 3, 2026
220fa06
fix(scale-runners): restore provider callback ordering
edersonbrilhante Sep 3, 2026
6073517
revert(scale-runners): restore JIT callback ordering
edersonbrilhante Sep 3, 2026
8f82069
refactor(tests): keep MicroVM coverage in provider layer
edersonbrilhante Sep 3, 2026
fe92f37
fix(microvm): use shared runner source type
edersonbrilhante Sep 3, 2026
dfd8382
fix(scale-runners): preserve existing JIT config ordering
edersonbrilhante Sep 3, 2026
8d9e62b
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
2 changes: 1 addition & 1 deletion lambdas/functions/control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
},
"dependencies": {
"@aws-github-runner/aws-powertools-util": "*",
"@aws-github-runner/aws-ssm-util": "*",
"@aws-github-runner/compute-providers": "*",
"@aws-github-runner/storage-providers": "*",
"@aws-lambda-powertools/parameters": "^2.31.0",
"@aws-sdk/client-ec2": "^3.1009.0",
"@aws-sdk/client-sqs": "^3.1009.0",
Expand Down
228 changes: 56 additions & 172 deletions lambdas/functions/control-plane/src/github/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ import { createAppAuth } from '@octokit/auth-app';
import { StrategyOptions } from '@octokit/auth-app/dist-types/types';
import { request } from '@octokit/request';
import { RequestInterface, RequestParameters } from '@octokit/types';
import { getParameters } from '@aws-github-runner/aws-ssm-util';
import {
getGitHubAppCredentialsStore,
type GitHubAppCredential,
type GitHubAppCredentialsStore,
} from '@aws-github-runner/storage-providers';
import { generateKeyPairSync } from 'node:crypto';
import * as nock from 'nock';

import {
createGithubAppAuth,
createOctokitClient,
getAppCount,
getAppId,
getStoredInstallationId,
onRateLimit,
onSecondaryRateLimit,
Expand All @@ -25,24 +31,27 @@ type MockProxy<T> = T & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mock = <T>(implementation?: any): MockProxy<T> => vi.fn(implementation) as any;

vi.mock('@aws-github-runner/aws-ssm-util');
vi.mock('@aws-github-runner/storage-providers', () => ({
getGitHubAppCredentialsStore: vi.fn(),
}));
vi.mock('@octokit/auth-app');

const cleanEnv = process.env;
const ENVIRONMENT = 'dev';
const GITHUB_APP_ID = '1';
const PARAMETER_GITHUB_APP_ID_NAME = `/actions-runner/${ENVIRONMENT}/github_app_id`;
const PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`;
const GITHUB_APP_ID = 1;

const mockedGetParameters = vi.mocked(getParameters);
const mockedGetGitHubAppCredentialsStore = vi.mocked(getGitHubAppCredentialsStore);
const mockCredentialsGet = vi.fn<GitHubAppCredentialsStore['get']>();
const credentialsStore = {
get: mockCredentialsGet,
} satisfies GitHubAppCredentialsStore;

beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockCredentialsGet.mockReset();
resetAppCredentialsCache();
process.env = { ...cleanEnv };
process.env.PARAMETER_GITHUB_APP_ID_NAME = PARAMETER_GITHUB_APP_ID_NAME;
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = PARAMETER_GITHUB_APP_KEY_BASE64_NAME;
mockedGetGitHubAppCredentialsStore.mockReturnValue(credentialsStore);
nock.disableNetConnect();
});

Expand Down Expand Up @@ -80,38 +89,18 @@ describe('Test createGithubAppAuth', () => {
const authType = 'app';
const token = '123456';
const decryptedValue = 'decryptedValue';
const b64 = Buffer.from(decryptedValue, 'binary').toString('base64');

beforeEach(() => {
process.env.ENVIRONMENT = ENVIRONMENT;
});

it('Throws early when PARAMETER_GITHUB_APP_ID_NAME is not set', async () => {
delete process.env.PARAMETER_GITHUB_APP_ID_NAME;
it('Propagates errors from the credential store', async () => {
const error = new Error('Unable to load GitHub App credentials');
mockCredentialsGet.mockRejectedValueOnce(error);

await expect(createGithubAppAuth(installationId)).rejects.toThrow(
'Environment variable PARAMETER_GITHUB_APP_ID_NAME is not set',
);
expect(mockedGetParameters).not.toHaveBeenCalled();
});

it('Throws early when PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set', async () => {
delete process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME;

await expect(createGithubAppAuth(installationId)).rejects.toThrow(
'Environment variable PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set',
);
expect(mockedGetParameters).not.toHaveBeenCalled();
await expect(createGithubAppAuth(installationId)).rejects.toBe(error);
expect(mockCredentialsGet).toHaveBeenCalledOnce();
});

it('Creates auth object with createJwt callback including jti claim', async () => {
// Arrange
mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
]),
);
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]);

const mockedAuth = vi.fn();
mockedAuth.mockResolvedValue({ token });
Expand All @@ -124,7 +113,7 @@ describe('Test createGithubAppAuth', () => {
// Assert
expect(mockedCreatAppAuth).toBeCalledTimes(1);
const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID));
expect(callArgs.appId).toBe(GITHUB_APP_ID);
expect(callArgs.createJwt).toBeTypeOf('function');
expect(callArgs).not.toHaveProperty('privateKey');
expect(callArgs.installationId).toBe(installationId);
Expand All @@ -137,14 +126,7 @@ describe('Test createGithubAppAuth', () => {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
const b64Key = Buffer.from(privateKey as string).toString('base64');

mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64Key],
]),
);
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: privateKey as string }]);

let capturedCreateJwt: (appId: string | number, timeDifference?: number) => Promise<{ jwt: string }>;
mockedCreatAppAuth.mockImplementation((opts: StrategyOptions) => {
Expand Down Expand Up @@ -173,41 +155,9 @@ describe('Test createGithubAppAuth', () => {
expect(payload).toHaveProperty('iss');
});

it('Creates auth object with line breaks in SSH key.', async () => {
// Arrange
const b64PrivateKeyWithLineBreaks = Buffer.from(decryptedValue + '\n' + decryptedValue, 'binary').toString(
'base64',
);
mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64PrivateKeyWithLineBreaks],
]),
);

const mockedAuth = vi.fn();
mockedAuth.mockResolvedValue({ token });
const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
mockedCreatAppAuth.mockReturnValue(mockWithHook);

// Act
const result = await createGithubAppAuth(installationId);

// Assert
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);
expect(mockedCreatAppAuth).toBeCalledTimes(1);
expect(mockedAuth).toBeCalledWith({ type: authType });
expect(result.token).toBe(token);
});

it('Creates auth object for public GitHub', async () => {
// Arrange
mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
]),
);
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]);

const mockedAuth = vi.fn();
mockedAuth.mockResolvedValue({ token });
Expand All @@ -218,11 +168,9 @@ describe('Test createGithubAppAuth', () => {
const result = await createGithubAppAuth(installationId);

// Assert
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);

expect(mockedCreatAppAuth).toBeCalledTimes(1);
const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID));
expect(callArgs.appId).toBe(GITHUB_APP_ID);
expect(callArgs.createJwt).toBeTypeOf('function');
expect(callArgs.installationId).toBe(installationId);
expect(mockedAuth).toBeCalledWith({ type: authType });
Expand All @@ -238,12 +186,7 @@ describe('Test createGithubAppAuth', () => {
() => mockedRequestInterface as RequestInterface<object & RequestParameters>,
);

mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
]),
);
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]);
const mockedAuth = vi.fn();
mockedAuth.mockResolvedValue({ token });
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -255,11 +198,9 @@ describe('Test createGithubAppAuth', () => {
const result = await createGithubAppAuth(installationId, githubServerUrl);

// Assert
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);

expect(mockedCreatAppAuth).toBeCalledTimes(1);
const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID));
expect(callArgs.appId).toBe(GITHUB_APP_ID);
expect(callArgs.createJwt).toBeTypeOf('function');
expect(callArgs.installationId).toBe(installationId);
expect(callArgs.request).toBeDefined();
Expand All @@ -278,12 +219,7 @@ describe('Test createGithubAppAuth', () => {

const installationId = undefined;

mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
]),
);
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]);
const mockedAuth = vi.fn();
mockedAuth.mockResolvedValue({ token });
const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
Expand All @@ -293,11 +229,9 @@ describe('Test createGithubAppAuth', () => {
const result = await createGithubAppAuth(installationId, githubServerUrl);

// Assert
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);

expect(mockedCreatAppAuth).toBeCalledTimes(1);
const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID));
expect(callArgs.appId).toBe(GITHUB_APP_ID);
expect(callArgs.createJwt).toBeTypeOf('function');
expect(callArgs).not.toHaveProperty('installationId');
expect(callArgs.request).toBeDefined();
Expand Down Expand Up @@ -330,98 +264,48 @@ describe('Test throttling retry caps', () => {
});
});

describe('Test getStoredInstallationId', () => {
const decryptedValue = 'decryptedValue';
const b64 = Buffer.from(decryptedValue, 'binary').toString('base64');

beforeEach(() => {
const mockedAuth = vi.fn();
mockedAuth.mockResolvedValue({ token: 'token' });
const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
vi.mocked(createAppAuth).mockReturnValue(mockWithHook);
});

describe('Test GitHub App credential accessors', () => {
it('returns stored installation ID when configured', async () => {
const installationIdParam = `/actions-runner/${ENVIRONMENT}/github_app_installation_id`;
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = installationIdParam;
mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
[installationIdParam, '12345'],
]),
);
mockCredentialsGet.mockResolvedValueOnce([
{ appId: GITHUB_APP_ID, privateKey: 'private-key', installationId: 12345 },
]);

const result = await getStoredInstallationId(0);
expect(result).toBe(12345);
});

it('returns undefined when installation ID param is empty', async () => {
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
]),
);

const result = await getStoredInstallationId(0);
expect(result).toBeUndefined();
});

it('returns undefined when env var is not set', async () => {
delete process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME;
mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
]),
);
it('returns undefined when the credential has no installation ID', async () => {
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: 'private-key' }]);

const result = await getStoredInstallationId(0);
expect(result).toBeUndefined();
});

it('returns undefined for out-of-bounds appIndex', async () => {
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
mockedGetParameters.mockResolvedValueOnce(
new Map([
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
]),
);
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: 'private-key' }]);

const result = await getStoredInstallationId(99);
expect(result).toBeUndefined();
});

it('loads installation IDs for multi-app setup', async () => {
const app1IdParam = `/actions-runner/${ENVIRONMENT}/github_app_id`;
const app2IdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_id`;
const app1KeyParam = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`;
const app2KeyParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_key_base64`;
const app2InstallParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_installation_id`;

process.env.PARAMETER_GITHUB_APP_ID_NAME = `${app1IdParam}:${app2IdParam}`;
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `${app1KeyParam}:${app2KeyParam}`;
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = `:${app2InstallParam}`;

mockedGetParameters.mockResolvedValueOnce(
new Map([
[app1IdParam, '1'],
[app1KeyParam, b64],
[app2IdParam, '2'],
[app2KeyParam, b64],
[app2InstallParam, '67890'],
]),
);
it('loads multi-app credentials once and exposes values by index', async () => {
const credentials: GitHubAppCredential[] = [
{ appId: 1, privateKey: 'private-key-1' },
{ appId: 2, privateKey: 'private-key-2', installationId: 67890 },
];
mockCredentialsGet.mockResolvedValueOnce(credentials);

await expect(getAppCount()).resolves.toBe(2);
await expect(getAppId()).resolves.toBe('1');
await expect(getAppId(1)).resolves.toBe('2');
await expect(getStoredInstallationId(0)).resolves.toBeUndefined();
await expect(getStoredInstallationId(1)).resolves.toBe(67890);
expect(mockCredentialsGet).toHaveBeenCalledOnce();
});

// Primary app (index 0) has no stored installation ID
const result0 = await getStoredInstallationId(0);
expect(result0).toBeUndefined();
it('throws a clear error for an out-of-bounds app ID index', async () => {
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: 'private-key' }]);

// Additional app (index 1) has stored installation ID
const result1 = await getStoredInstallationId(1);
expect(result1).toBe(67890);
await expect(getAppId(99)).rejects.toThrow('GitHub App credential at index 99 not found');
});
});
Loading