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
1 change: 1 addition & 0 deletions lambdas/functions/control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@middy/core": "^6.4.5",
"@octokit/auth-app": "8.1.2",
"@octokit/core": "7.0.6",
"@octokit/plugin-retry": "8.0.3",
"@octokit/plugin-throttling": "11.0.3",
"@octokit/rest": "22.0.1",
"cron-parser": "^5.4.0"
Expand Down
14 changes: 13 additions & 1 deletion lambdas/functions/control-plane/src/github/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type StrategyOptions = {
};
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 { getParameter } from '@aws-github-runner/aws-ssm-util';
Expand All @@ -26,7 +27,7 @@ import { EndpointDefaults } from '@octokit/types';
const logger = createChildLogger('gh-auth');

export async function createOctokitClient(token: string, ghesApiUrl = ''): Promise<Octokit> {
const CustomOctokit = Octokit.plugin(throttling);
const CustomOctokit = Octokit.plugin(retry, throttling);
const ocktokitOptions: OctokitOptions = {
auth: token,
};
Expand All @@ -38,6 +39,17 @@ export async function createOctokitClient(token: string, ghesApiUrl = ''): Promi
return new CustomOctokit({
...ocktokitOptions,
userAgent: process.env.USER_AGENT || 'github-aws-runners',
retry: {
onRetry: (retryCount: number, error: Error, request: { method: string; url: string }) => {
logger.warn('GitHub API request retry attempt', {
retryCount,
method: request.method,
url: request.url,
error: error.message,
status: (error as Error & { status?: number }).status,
});
},
},
throttle: {
onRateLimit: (retryAfter: number, options: Required<EndpointDefaults>) => {
logger.warn(
Expand Down
166 changes: 166 additions & 0 deletions lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,172 @@ describe('scaleUp with GHES', () => {
],
});
});

it('should create JIT config for all remaining instances even when GitHub API fails for one instance', async () => {
process.env.RUNNERS_MAXIMUM_COUNT = '5';
mockCreateRunner.mockImplementation(async () => {
return ['i-instance-1', 'i-instance-2', 'i-instance-3'];
});
mockListRunners.mockImplementation(async () => {
return [];
});

mockOctokit.actions.generateRunnerJitconfigForOrg.mockImplementation(({ name }) => {
if (name === 'unit-test-i-instance-2') {
// Simulate a 503 Service Unavailable error from GitHub
const error = new Error('Service Unavailable') as Error & {
status: number;
response: { status: number; data: { message: string } };
};
error.status = 503;
error.response = {
status: 503,
data: { message: 'Service temporarily unavailable' },
};
throw error;
}
return {
data: {
runner: { id: 9876543210 },
encoded_jit_config: `TEST_JIT_CONFIG_${name}`,
},
headers: {},
};
});

await scaleUpModule.scaleUp(TEST_DATA);

expect(mockOctokit.actions.generateRunnerJitconfigForOrg).toHaveBeenCalledWith({
org: TEST_DATA_SINGLE.repositoryOwner,
name: 'unit-test-i-instance-1',
runner_group_id: 1,
labels: ['label1', 'label2'],
});

expect(mockOctokit.actions.generateRunnerJitconfigForOrg).toHaveBeenCalledWith({
org: TEST_DATA_SINGLE.repositoryOwner,
name: 'unit-test-i-instance-2',
runner_group_id: 1,
labels: ['label1', 'label2'],
});

expect(mockOctokit.actions.generateRunnerJitconfigForOrg).toHaveBeenCalledWith({
org: TEST_DATA_SINGLE.repositoryOwner,
name: 'unit-test-i-instance-3',
runner_group_id: 1,
labels: ['label1', 'label2'],
});

expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-instance-1',
Value: 'TEST_JIT_CONFIG_unit-test-i-instance-1',
Type: 'SecureString',
Tags: [{ Key: 'InstanceId', Value: 'i-instance-1' }],
});

expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-instance-3',
Value: 'TEST_JIT_CONFIG_unit-test-i-instance-3',
Type: 'SecureString',
Tags: [{ Key: 'InstanceId', Value: 'i-instance-3' }],
});

expect(mockSSMClient).not.toHaveReceivedCommandWith(PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-instance-2',
});
});

it('should handle retryable errors with error handling logic', async () => {
process.env.RUNNERS_MAXIMUM_COUNT = '5';
mockCreateRunner.mockImplementation(async () => {
return ['i-instance-1', 'i-instance-2'];
});
mockListRunners.mockImplementation(async () => {
return [];
});

mockOctokit.actions.generateRunnerJitconfigForOrg.mockImplementation(({ name }) => {
if (name === 'unit-test-i-instance-1') {
const error = new Error('Internal Server Error') as Error & {
status: number;
response: { status: number; data: { message: string } };
};
error.status = 500;
error.response = {
status: 500,
data: { message: 'Internal server error' },
};
throw error;
}
return {
data: {
runner: { id: 9876543210 },
encoded_jit_config: `TEST_JIT_CONFIG_${name}`,
},
headers: {},
};
});

await scaleUpModule.scaleUp(TEST_DATA);

expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-instance-2',
Value: 'TEST_JIT_CONFIG_unit-test-i-instance-2',
Type: 'SecureString',
Tags: [{ Key: 'InstanceId', Value: 'i-instance-2' }],
});

expect(mockSSMClient).not.toHaveReceivedCommandWith(PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-instance-1',
});
});

it('should handle non-retryable 4xx errors gracefully', async () => {
process.env.RUNNERS_MAXIMUM_COUNT = '5';
mockCreateRunner.mockImplementation(async () => {
return ['i-instance-1', 'i-instance-2'];
});
mockListRunners.mockImplementation(async () => {
return [];
});

mockOctokit.actions.generateRunnerJitconfigForOrg.mockImplementation(({ name }) => {
if (name === 'unit-test-i-instance-1') {
// 404 is not retryable - will fail immediately
const error = new Error('Not Found') as Error & {
status: number;
response: { status: number; data: { message: string } };
};
error.status = 404;
error.response = {
status: 404,
data: { message: 'Resource not found' },
};
throw error;
}
return {
data: {
runner: { id: 9876543210 },
encoded_jit_config: `TEST_JIT_CONFIG_${name}`,
},
headers: {},
};
});

await scaleUpModule.scaleUp(TEST_DATA);

expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-instance-2',
Value: 'TEST_JIT_CONFIG_unit-test-i-instance-2',
Type: 'SecureString',
Tags: [{ Key: 'InstanceId', Value: 'i-instance-2' }],
});

expect(mockSSMClient).not.toHaveReceivedCommandWith(PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-instance-1',
});
});

it.each(RUNNER_TYPES)(
'calls create start runner config of 40' + ' instances (ssm rate limit condition) to test time delay ',
async (type: RunnerType) => {
Expand Down
Loading