Skip to content

Commit 128f662

Browse files
committed
Refactor to have predictive names
1 parent 53e9b27 commit 128f662

File tree

14 files changed

+5368
-0
lines changed

14 files changed

+5368
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v12.16.1
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"printWidth": 120,
3+
"singleQuote": true,
4+
"trailingComma": "all"
5+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "github-runner-lambda-scale-runners",
3+
"version": "1.0.0",
4+
"main": "lambda.ts",
5+
"license": "MIT",
6+
"scripts": {
7+
"start": "ts-node-dev src/local.ts",
8+
"test": "NODE_ENV=test jest",
9+
"watch": "ts-node-dev --respawn --exit-child src/local.ts",
10+
"build": "ncc build src/lambda.ts -o dist",
11+
"dist": "yarn build && cd dist && zip ../runners.zip index.js"
12+
},
13+
"devDependencies": {
14+
"@types/aws-lambda": "^8.10.51",
15+
"@types/express": "^4.17.3",
16+
"@types/jest": "^25.2.1",
17+
"@types/node": "^13.13.4",
18+
"@zeit/ncc": "^0.22.1",
19+
"aws-sdk": "^2.671.0",
20+
"jest": "^25.4.0",
21+
"ts-jest": "^25.4.0",
22+
"ts-node-dev": "^1.0.0-pre.44",
23+
"typescript": "^3.8.3"
24+
},
25+
"dependencies": {
26+
"@octokit/auth-app": "^2.4.5",
27+
"@octokit/rest": "^17.6.0",
28+
"moment": "^2.25.3",
29+
"yn": "^4.0.0"
30+
}
31+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { scaleUp } from './scale-runners/scale-up';
2+
import { scaleDown } from './scale-runners/scale-down';
3+
import { SQSEvent } from 'aws-lambda';
4+
5+
module.exports.scaleUp = async (event: SQSEvent, context: any, callback: any) => {
6+
console.log(event);
7+
try {
8+
for (const e of event.Records) {
9+
await scaleUp(e.eventSource, JSON.parse(e.body));
10+
}
11+
return callback(null);
12+
} catch (e) {
13+
console.error(e);
14+
return callback('Failed handling SQS event');
15+
}
16+
};
17+
18+
module.exports.scaleDown = async (event: any, context: any, callback: any) => {
19+
try {
20+
scaleDown();
21+
return callback(null);
22+
} catch (e) {
23+
console.error(e);
24+
return callback('Failed');
25+
}
26+
};
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { listRunners, RunnerInfo, createRunner } from './runners';
2+
import { EC2, SSM } from 'aws-sdk';
3+
4+
const mockEC2 = { describeInstances: jest.fn(), runInstances: jest.fn() };
5+
const mockSSM = { putParameter: jest.fn() };
6+
jest.mock('aws-sdk', () => ({
7+
EC2: jest.fn().mockImplementation(() => mockEC2),
8+
SSM: jest.fn().mockImplementation(() => mockSSM),
9+
}));
10+
11+
describe('list instances', () => {
12+
const mockDescribeInstances = { promise: jest.fn() };
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
mockEC2.describeInstances.mockImplementation(() => mockDescribeInstances);
16+
const mockRunningInstances: AWS.EC2.DescribeInstancesResult = {
17+
Reservations: [
18+
{
19+
Instances: [
20+
{
21+
LaunchTime: new Date('2020-10-10T14:48:00.000+09:00'),
22+
InstanceId: 'i-1234',
23+
Tags: [
24+
{ Key: 'Repo', Value: 'CoderToCat/hello-world' },
25+
{ Key: 'Org', Value: 'CoderToCat' },
26+
{ Key: 'Application', Value: 'github-action-runner' },
27+
],
28+
},
29+
{
30+
LaunchTime: new Date('2020-10-11T14:48:00.000+09:00'),
31+
InstanceId: 'i-5678',
32+
Tags: [
33+
{ Key: 'Repo', Value: 'SomeAwesomeCoder/some-amazing-library' },
34+
{ Key: 'Org', Value: 'SomeAwesomeCoder' },
35+
{ Key: 'Application', Value: 'github-action-runner' },
36+
],
37+
},
38+
],
39+
},
40+
],
41+
};
42+
mockDescribeInstances.promise.mockReturnValue(mockRunningInstances);
43+
});
44+
45+
it('returns a list of instances', async () => {
46+
const resp = await listRunners();
47+
expect(resp.length).toBe(2);
48+
expect(resp).toContainEqual({
49+
instanceId: 'i-1234',
50+
launchTime: new Date('2020-10-10T14:48:00.000+09:00'),
51+
repo: 'CoderToCat/hello-world',
52+
org: 'CoderToCat',
53+
});
54+
expect(resp).toContainEqual({
55+
instanceId: 'i-5678',
56+
launchTime: new Date('2020-10-11T14:48:00.000+09:00'),
57+
repo: 'SomeAwesomeCoder/some-amazing-library',
58+
org: 'SomeAwesomeCoder',
59+
});
60+
});
61+
62+
it('calls EC2 describe instances', async () => {
63+
await listRunners();
64+
expect(mockEC2.describeInstances).toBeCalled();
65+
});
66+
67+
it('filters instances on repo name', async () => {
68+
await listRunners({ repoName: 'SomeAwesomeCoder/some-amazing-library' });
69+
expect(mockEC2.describeInstances).toBeCalledWith({
70+
Filters: [
71+
{ Name: 'tag:Application', Values: ['github-action-runner'] },
72+
{ Name: 'instance-state-name', Values: ['running', 'pending'] },
73+
{ Name: 'tag:Repo', Values: ['SomeAwesomeCoder/some-amazing-library'] },
74+
],
75+
});
76+
});
77+
78+
it('filters instances on org name', async () => {
79+
await listRunners({ orgName: 'SomeAwesomeCoder' });
80+
expect(mockEC2.describeInstances).toBeCalledWith({
81+
Filters: [
82+
{ Name: 'tag:Application', Values: ['github-action-runner'] },
83+
{ Name: 'instance-state-name', Values: ['running', 'pending'] },
84+
{ Name: 'tag:Org', Values: ['SomeAwesomeCoder'] },
85+
],
86+
});
87+
});
88+
89+
it('filters instances on org name', async () => {
90+
await listRunners({ environment: 'unit-test-environment' });
91+
expect(mockEC2.describeInstances).toBeCalledWith({
92+
Filters: [
93+
{ Name: 'tag:Application', Values: ['github-action-runner'] },
94+
{ Name: 'instance-state-name', Values: ['running', 'pending'] },
95+
{ Name: 'tag:Environment', Values: ['unit-test-environment'] },
96+
],
97+
});
98+
});
99+
100+
it('filters instances on both org name and repo name', async () => {
101+
await listRunners({ orgName: 'SomeAwesomeCoder', repoName: 'SomeAwesomeCoder/some-amazing-library' });
102+
expect(mockEC2.describeInstances).toBeCalledWith({
103+
Filters: [
104+
{ Name: 'tag:Application', Values: ['github-action-runner'] },
105+
{ Name: 'instance-state-name', Values: ['running', 'pending'] },
106+
{ Name: 'tag:Repo', Values: ['SomeAwesomeCoder/some-amazing-library'] },
107+
{ Name: 'tag:Org', Values: ['SomeAwesomeCoder'] },
108+
],
109+
});
110+
});
111+
});
112+
113+
describe('create runner', () => {
114+
const mockRunInstances = { promise: jest.fn() };
115+
const mockPutParameter = { promise: jest.fn() };
116+
beforeEach(() => {
117+
jest.clearAllMocks();
118+
mockEC2.runInstances.mockImplementation(() => mockRunInstances);
119+
mockRunInstances.promise.mockReturnValue({
120+
Instances: [
121+
{
122+
InstanceId: 'i-1234',
123+
},
124+
],
125+
});
126+
mockSSM.putParameter.mockImplementation(() => mockPutParameter);
127+
process.env.LAUNCH_TEMPLATE_NAME = 'launch-template-name';
128+
process.env.LAUNCH_TEMPLATE_VERSION = '1';
129+
process.env.SUBNET_IDS = 'sub-1234';
130+
});
131+
132+
it('calls run instances with the correct config for repo', async () => {
133+
await createRunner({
134+
runnerConfig: 'bla',
135+
environment: 'unit-test-env',
136+
repoName: 'SomeAwesomeCoder/some-amazing-library',
137+
orgName: undefined,
138+
});
139+
expect(mockEC2.runInstances).toBeCalledWith({
140+
MaxCount: 1,
141+
MinCount: 1,
142+
LaunchTemplate: { LaunchTemplateName: 'launch-template-name', Version: '1' },
143+
SubnetId: 'sub-1234',
144+
TagSpecifications: [
145+
{
146+
ResourceType: 'instance',
147+
Tags: [
148+
{ Key: 'Application', Value: 'github-action-runner' },
149+
{ Key: 'Repo', Value: 'SomeAwesomeCoder/some-amazing-library' },
150+
],
151+
},
152+
],
153+
});
154+
});
155+
156+
it('calls run instances with the correct config for org', async () => {
157+
await createRunner({
158+
runnerConfig: 'bla',
159+
environment: 'unit-test-env',
160+
repoName: undefined,
161+
orgName: 'SomeAwesomeCoder',
162+
});
163+
expect(mockEC2.runInstances).toBeCalledWith({
164+
MaxCount: 1,
165+
MinCount: 1,
166+
LaunchTemplate: { LaunchTemplateName: 'launch-template-name', Version: '1' },
167+
SubnetId: 'sub-1234',
168+
TagSpecifications: [
169+
{
170+
ResourceType: 'instance',
171+
Tags: [
172+
{ Key: 'Application', Value: 'github-action-runner' },
173+
{ Key: 'Org', Value: 'SomeAwesomeCoder' },
174+
],
175+
},
176+
],
177+
});
178+
});
179+
180+
it('creates ssm parameters for each created instance', async () => {
181+
await createRunner({
182+
runnerConfig: 'bla',
183+
environment: 'unit-test-env',
184+
repoName: undefined,
185+
orgName: 'SomeAwesomeCoder',
186+
});
187+
expect(mockSSM.putParameter).toBeCalledWith({
188+
Name: 'unit-test-env-i-1234',
189+
Value: 'bla',
190+
Type: 'SecureString',
191+
});
192+
});
193+
194+
it('does not create ssm parameters when no instance is created', async () => {
195+
mockRunInstances.promise.mockReturnValue({
196+
Instances: [],
197+
});
198+
await createRunner({
199+
runnerConfig: 'bla',
200+
environment: 'unit-test-env',
201+
repoName: undefined,
202+
orgName: 'SomeAwesomeCoder',
203+
});
204+
expect(mockSSM.putParameter).not.toBeCalled();
205+
});
206+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { EC2, SSM } from 'aws-sdk';
2+
3+
export interface RunnerInfo {
4+
instanceId: string;
5+
launchTime: Date | undefined;
6+
repo: string | undefined;
7+
org: string | undefined;
8+
}
9+
10+
export interface ListRunnerFilters {
11+
repoName?: string;
12+
orgName?: string;
13+
environment?: string;
14+
}
15+
16+
export async function listRunners(filters: ListRunnerFilters | undefined = undefined): Promise<RunnerInfo[]> {
17+
const ec2 = new EC2();
18+
let ec2Filters = [
19+
{ Name: 'tag:Application', Values: ['github-action-runner'] },
20+
{ Name: 'instance-state-name', Values: ['running', 'pending'] },
21+
];
22+
if (filters) {
23+
if (filters.environment !== undefined) {
24+
ec2Filters.push({ Name: 'tag:Environment', Values: [filters.environment] });
25+
}
26+
if (filters.repoName !== undefined) {
27+
ec2Filters.push({ Name: 'tag:Repo', Values: [filters.repoName] });
28+
}
29+
if (filters.orgName !== undefined) {
30+
ec2Filters.push({ Name: 'tag:Org', Values: [filters.orgName] });
31+
}
32+
}
33+
const runningInstances = await ec2.describeInstances({ Filters: ec2Filters }).promise();
34+
const runners: RunnerInfo[] = [];
35+
if (runningInstances.Reservations) {
36+
for (const r of runningInstances.Reservations) {
37+
if (r.Instances) {
38+
for (const i of r.Instances) {
39+
runners.push({
40+
instanceId: i.InstanceId as string,
41+
launchTime: i.LaunchTime,
42+
repo: i.Tags?.find((e) => e.Key === 'Repo')?.Value,
43+
org: i.Tags?.find((e) => e.Key === 'Org')?.Value,
44+
});
45+
}
46+
}
47+
}
48+
}
49+
return runners;
50+
}
51+
52+
export interface RunnerInputParameters {
53+
runnerConfig: string;
54+
environment: string;
55+
repoName?: string;
56+
orgName?: string;
57+
}
58+
59+
export async function terminateRunner(runner: RunnerInfo): Promise<void> {
60+
const ec2 = new EC2();
61+
const result = await ec2
62+
.terminateInstances({
63+
InstanceIds: [runner.instanceId],
64+
})
65+
.promise();
66+
console.debug('Runner terminated.' + result.TerminatingInstances);
67+
}
68+
69+
export async function createRunner(runnerParameters: RunnerInputParameters): Promise<void> {
70+
const launchTemplateName = process.env.LAUNCH_TEMPLATE_NAME as string;
71+
const launchTemplateVersion = process.env.LAUNCH_TEMPLATE_VERSION as string;
72+
73+
const subnets = (process.env.SUBNET_IDS as string).split(',');
74+
const randomSubnet = subnets[Math.floor(Math.random() * subnets.length)];
75+
console.debug('Runner configuration: ' + JSON.stringify(runnerParameters));
76+
const ec2 = new EC2();
77+
const runInstancesResponse = await ec2
78+
.runInstances({
79+
MaxCount: 1,
80+
MinCount: 1,
81+
LaunchTemplate: {
82+
LaunchTemplateName: launchTemplateName,
83+
Version: launchTemplateVersion,
84+
},
85+
SubnetId: randomSubnet,
86+
TagSpecifications: [
87+
{
88+
ResourceType: 'instance',
89+
Tags: [
90+
{ Key: 'Application', Value: 'github-action-runner' },
91+
{
92+
Key: runnerParameters.orgName ? 'Org' : 'Repo',
93+
Value: runnerParameters.orgName ? runnerParameters.orgName : runnerParameters.repoName,
94+
},
95+
],
96+
},
97+
],
98+
})
99+
.promise();
100+
console.info('Created instance(s): ', runInstancesResponse.Instances?.map((i) => i.InstanceId).join(','));
101+
102+
const ssm = new SSM();
103+
runInstancesResponse.Instances?.forEach(async (i: EC2.Instance) => {
104+
await ssm
105+
.putParameter({
106+
Name: runnerParameters.environment + '-' + (i.InstanceId as string),
107+
Value: runnerParameters.runnerConfig,
108+
Type: 'SecureString',
109+
})
110+
.promise();
111+
});
112+
}

0 commit comments

Comments
 (0)