Skip to content

Commit d888b4d

Browse files
committed
Support multiple EC2 runners
The code here is based upon machulav#82 with some updates to match the latest runner version. My IDE also decided to run a formatter, so there are a few formatting changes that don't change functionality.
1 parent fcfb31a commit d888b4d

File tree

5 files changed

+58
-41
lines changed

5 files changed

+58
-41
lines changed

action.yml

+10-4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ inputs:
2020
EC2 Image Id (AMI). The new runner will be launched from this image.
2121
This input is required if you use the 'start' mode.
2222
required: false
23+
ec2-instance-count:
24+
description: >-
25+
Number of EC2 runners to launch, defaults to 1.
26+
required: false
27+
default: '1'
2328
ec2-instance-type:
2429
description: >-
2530
EC2 Instance Type.
@@ -43,12 +48,13 @@ inputs:
4348
The label is used to remove the runner from GitHub when the runner is not needed anymore.
4449
This input is required if you use the 'stop' mode.
4550
required: false
46-
ec2-instance-id:
51+
ec2-instance-ids:
4752
description: >-
48-
EC2 Instance Id of the created runner.
53+
EC2 Instance Id(s) of the created runner(s).
4954
The id is used to terminate the EC2 instance when the runner is not needed anymore.
5055
This input is required if you use the 'stop' mode.
5156
required: false
57+
default: 'null'
5258
iam-role-name:
5359
description: >-
5460
IAM Role Name to attach to the created EC2 instance.
@@ -77,9 +83,9 @@ outputs:
7783
The label is used in two cases:
7884
- to use as the input of 'runs-on' property for the following jobs;
7985
- to remove the runner from GitHub when it is not needed anymore.
80-
ec2-instance-id:
86+
ec2-instance-ids:
8187
description: >-
82-
EC2 Instance Id of the created runner.
88+
EC2 Instance Id(s) of the created runner(s).
8389
The id is used to terminate the EC2 instance when the runner is not needed anymore.
8490
runs:
8591
using: node20

src/aws.js

+12-12
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
4040
const params = {
4141
ImageId: config.input.ec2ImageId,
4242
InstanceType: config.input.ec2InstanceType,
43-
MinCount: 1,
44-
MaxCount: 1,
43+
MinCount: config.input.ec2InstanceCount,
44+
MaxCount: config.input.ec2InstanceCount,
4545
UserData: Buffer.from(userData.join('\n')).toString('base64'),
4646
SubnetId: config.input.subnetId,
4747
SecurityGroupIds: [config.input.securityGroupId],
@@ -51,9 +51,9 @@ async function startEc2Instance(label, githubRegistrationToken) {
5151

5252
try {
5353
const result = await ec2.runInstances(params).promise();
54-
const ec2InstanceId = result.Instances[0].InstanceId;
55-
core.info(`AWS EC2 instance ${ec2InstanceId} is started`);
56-
return ec2InstanceId;
54+
const ec2InstanceIds = result.Instances.map((inst) => inst.InstanceId);
55+
core.info(`AWS EC2 instances ${ec2InstanceIds} are started`);
56+
return ec2InstanceIds;
5757
} catch (error) {
5858
core.error('AWS EC2 instance starting error');
5959
throw error;
@@ -64,32 +64,32 @@ async function terminateEc2Instance() {
6464
const ec2 = new AWS.EC2();
6565

6666
const params = {
67-
InstanceIds: [config.input.ec2InstanceId],
67+
InstanceIds: config.input.ec2InstanceIds,
6868
};
6969

7070
try {
7171
await ec2.terminateInstances(params).promise();
72-
core.info(`AWS EC2 instance ${config.input.ec2InstanceId} is terminated`);
72+
core.info(`AWS EC2 instance ${config.input.ec2InstanceIds} are terminated`);
7373
return;
7474
} catch (error) {
75-
core.error(`AWS EC2 instance ${config.input.ec2InstanceId} termination error`);
75+
core.error(`AWS EC2 instance ${config.input.ec2InstanceIds} termination error`);
7676
throw error;
7777
}
7878
}
7979

80-
async function waitForInstanceRunning(ec2InstanceId) {
80+
async function waitForInstanceRunning(ec2InstanceIds) {
8181
const ec2 = new AWS.EC2();
8282

8383
const params = {
84-
InstanceIds: [ec2InstanceId],
84+
InstanceIds: ec2InstanceIds,
8585
};
8686

8787
try {
8888
await ec2.waitFor('instanceRunning', params).promise();
89-
core.info(`AWS EC2 instance ${ec2InstanceId} is up and running`);
89+
core.info(`AWS EC2 instances ${ec2InstanceIds} are up and running`);
9090
return;
9191
} catch (error) {
92-
core.error(`AWS EC2 instance ${ec2InstanceId} initialization error`);
92+
core.error(`AWS EC2 instances ${ec2InstanceIds} initialization error`);
9393
throw error;
9494
}
9595
}

src/config.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ class Config {
1111
subnetId: core.getInput('subnet-id'),
1212
securityGroupId: core.getInput('security-group-id'),
1313
label: core.getInput('label'),
14-
ec2InstanceId: core.getInput('ec2-instance-id'),
14+
ec2InstanceCount: core.getInput('ec2-instance-count'),
15+
ec2InstanceIds: JSON.parse(core.getInput('ec2-instance-ids')),
1516
iamRoleName: core.getInput('iam-role-name'),
1617
runnerHomeDir: core.getInput('runner-home-dir'),
1718
preRunnerScript: core.getInput('pre-runner-script'),
@@ -20,7 +21,10 @@ class Config {
2021
const tags = JSON.parse(core.getInput('aws-resource-tags'));
2122
this.tagSpecifications = null;
2223
if (tags.length > 0) {
23-
this.tagSpecifications = [{ResourceType: 'instance', Tags: tags}, {ResourceType: 'volume', Tags: tags}];
24+
this.tagSpecifications = [
25+
{ ResourceType: 'instance', Tags: tags },
26+
{ ResourceType: 'volume', Tags: tags },
27+
];
2428
}
2529

2630
// the values of github.context.repo.owner and github.context.repo.repo are taken from
@@ -48,7 +52,7 @@ class Config {
4852
throw new Error(`Not all the required inputs are provided for the 'start' mode`);
4953
}
5054
} else if (this.input.mode === 'stop') {
51-
if (!this.input.label || !this.input.ec2InstanceId) {
55+
if (!this.input.label || !this.input.ec2InstanceIds) {
5256
throw new Error(`Not all the required inputs are provided for the 'stop' mode`);
5357
}
5458
} else {

src/gh.js

+24-17
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ const config = require('./config');
55

66
// use the unique label to find the runner
77
// as we don't have the runner's id, it's not possible to get it in any other way
8-
async function getRunner(label) {
8+
async function getRunners(label) {
99
const octokit = github.getOctokit(config.input.githubToken);
1010

1111
try {
1212
const runners = await octokit.paginate('GET /repos/{owner}/{repo}/actions/runners', config.githubContext);
1313
const foundRunners = _.filter(runners, { labels: [{ name: label }] });
14-
return foundRunners.length > 0 ? foundRunners[0] : null;
14+
return foundRunners.length > 0 ? foundRunners : null;
1515
} catch (error) {
1616
return null;
1717
}
@@ -32,22 +32,27 @@ async function getRegistrationToken() {
3232
}
3333

3434
async function removeRunner() {
35-
const runner = await getRunner(config.input.label);
35+
const runners = await getRunners(config.input.label);
3636
const octokit = github.getOctokit(config.input.githubToken);
3737

3838
// skip the runner removal process if the runner is not found
39-
if (!runner) {
40-
core.info(`GitHub self-hosted runner with label ${config.input.label} is not found, so the removal is skipped`);
39+
if (!runners) {
40+
core.info(`GitHub self-hosted runners with label ${config.input.label} not found, so the removal is skipped`);
4141
return;
4242
}
4343

44-
try {
45-
await octokit.request('DELETE /repos/{owner}/{repo}/actions/runners/{runner_id}', _.merge(config.githubContext, { runner_id: runner.id }));
46-
core.info(`GitHub self-hosted runner ${runner.name} is removed`);
47-
return;
48-
} catch (error) {
49-
core.error('GitHub self-hosted runner removal error');
50-
throw error;
44+
const errors = runners.reduce(async (errors, runner) => {
45+
try {
46+
await octokit.request('DELETE /repos/{owner}/{repo}/actions/runners/{runner_id}', _.merge(config.githubContext, { runner_id: runner.id }));
47+
core.info(`GitHub self-hosted runner ${runner.name} is removed`);
48+
} catch (error) {
49+
core.error(`GitHub self-hosted runner ${runner} removal error: ${error}`);
50+
errors.push(error);
51+
}
52+
return errors;
53+
});
54+
if (errors.length > 0) {
55+
core.setFailure('Encountered error(s) removing self-hosted runner(s)');
5156
}
5257
}
5358

@@ -58,21 +63,23 @@ async function waitForRunnerRegistered(label) {
5863
let waitSeconds = 0;
5964

6065
core.info(`Waiting ${quietPeriodSeconds}s for the AWS EC2 instance to be registered in GitHub as a new self-hosted runner`);
61-
await new Promise(r => setTimeout(r, quietPeriodSeconds * 1000));
66+
await new Promise((r) => setTimeout(r, quietPeriodSeconds * 1000));
6267
core.info(`Checking every ${retryIntervalSeconds}s if the GitHub self-hosted runner is registered`);
6368

6469
return new Promise((resolve, reject) => {
6570
const interval = setInterval(async () => {
66-
const runner = await getRunner(label);
71+
const runners = await getRunners(label);
6772

6873
if (waitSeconds > timeoutMinutes * 60) {
6974
core.error('GitHub self-hosted runner registration error');
7075
clearInterval(interval);
71-
reject(`A timeout of ${timeoutMinutes} minutes is exceeded. Your AWS EC2 instance was not able to register itself in GitHub as a new self-hosted runner.`);
76+
reject(
77+
`A timeout of ${timeoutMinutes} minutes is exceeded. Your AWS EC2 instance was not able to register itself in GitHub as a new self-hosted runner.`
78+
);
7279
}
7380

74-
if (runner && runner.status === 'online') {
75-
core.info(`GitHub self-hosted runner ${runner.name} is registered and ready to use`);
81+
if (runners && runners.every((runner) => runner.status === 'online')) {
82+
core.info(`GitHub self-hosted runners ${runners} are registered and ready to use`);
7683
clearInterval(interval);
7784
resolve();
7885
} else {

src/index.js

+5-5
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@ const gh = require('./gh');
33
const config = require('./config');
44
const core = require('@actions/core');
55

6-
function setOutput(label, ec2InstanceId) {
6+
function setOutput(label, ec2InstanceIds) {
77
core.setOutput('label', label);
8-
core.setOutput('ec2-instance-id', ec2InstanceId);
8+
core.setOutput('ec2-instance-ids', ec2InstanceIds);
99
}
1010

1111
async function start() {
1212
const label = config.generateUniqueLabel();
1313
const githubRegistrationToken = await gh.getRegistrationToken();
14-
const ec2InstanceId = await aws.startEc2Instance(label, githubRegistrationToken);
15-
setOutput(label, ec2InstanceId);
16-
await aws.waitForInstanceRunning(ec2InstanceId);
14+
const ec2InstanceIds = await aws.startEc2Instance(label, githubRegistrationToken);
15+
setOutput(label, ec2InstanceIds);
16+
await aws.waitForInstanceRunning(ec2InstanceIds);
1717
await gh.waitForRunnerRegistered(label);
1818
}
1919

0 commit comments

Comments
 (0)