Skip to content

Commit

Permalink
Support multiple EC2 runners
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
brianjaustin committed May 17, 2024
1 parent fcfb31a commit 707d566
Show file tree
Hide file tree
Showing 5 changed files with 58 additions and 41 deletions.
14 changes: 10 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ inputs:
EC2 Image Id (AMI). The new runner will be launched from this image.
This input is required if you use the 'start' mode.
required: false
ec2-instance-count:
description: >-
Number of EC2 runners to launch, defaults to 1.
required: false
default: '1'
ec2-instance-type:
description: >-
EC2 Instance Type.
Expand All @@ -43,12 +48,13 @@ inputs:
The label is used to remove the runner from GitHub when the runner is not needed anymore.
This input is required if you use the 'stop' mode.
required: false
ec2-instance-id:
ec2-instance-ids:
description: >-
EC2 Instance Id of the created runner.
EC2 Instance Id(s) of the created runner(s).
The id is used to terminate the EC2 instance when the runner is not needed anymore.
This input is required if you use the 'stop' mode.
required: false
default: 'null'
iam-role-name:
description: >-
IAM Role Name to attach to the created EC2 instance.
Expand Down Expand Up @@ -77,9 +83,9 @@ outputs:
The label is used in two cases:
- to use as the input of 'runs-on' property for the following jobs;
- to remove the runner from GitHub when it is not needed anymore.
ec2-instance-id:
ec2-instance-ids:
description: >-
EC2 Instance Id of the created runner.
EC2 Instance Id(s) of the created runner(s).
The id is used to terminate the EC2 instance when the runner is not needed anymore.
runs:
using: node20
Expand Down
24 changes: 12 additions & 12 deletions src/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
const params = {
ImageId: config.input.ec2ImageId,
InstanceType: config.input.ec2InstanceType,
MinCount: 1,
MaxCount: 1,
MinCount: config.input.ec2InstanceCount,
MaxCount: config.input.ec2InstanceCount,
UserData: Buffer.from(userData.join('\n')).toString('base64'),
SubnetId: config.input.subnetId,
SecurityGroupIds: [config.input.securityGroupId],
Expand All @@ -51,9 +51,9 @@ async function startEc2Instance(label, githubRegistrationToken) {

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

const params = {
InstanceIds: [config.input.ec2InstanceId],
InstanceIds: config.input.ec2InstanceIds,
};

try {
await ec2.terminateInstances(params).promise();
core.info(`AWS EC2 instance ${config.input.ec2InstanceId} is terminated`);
core.info(`AWS EC2 instance ${config.input.ec2InstanceIds} are terminated`);
return;
} catch (error) {
core.error(`AWS EC2 instance ${config.input.ec2InstanceId} termination error`);
core.error(`AWS EC2 instance ${config.input.ec2InstanceIds} termination error`);
throw error;
}
}

async function waitForInstanceRunning(ec2InstanceId) {
async function waitForInstanceRunning(ec2InstanceIds) {
const ec2 = new AWS.EC2();

const params = {
InstanceIds: [ec2InstanceId],
InstanceIds: ec2InstanceIds,
};

try {
await ec2.waitFor('instanceRunning', params).promise();
core.info(`AWS EC2 instance ${ec2InstanceId} is up and running`);
core.info(`AWS EC2 instances ${ec2InstanceIds} are up and running`);
return;
} catch (error) {
core.error(`AWS EC2 instance ${ec2InstanceId} initialization error`);
core.error(`AWS EC2 instances ${ec2InstanceIds} initialization error`);
throw error;
}
}
Expand Down
10 changes: 7 additions & 3 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ class Config {
subnetId: core.getInput('subnet-id'),
securityGroupId: core.getInput('security-group-id'),
label: core.getInput('label'),
ec2InstanceId: core.getInput('ec2-instance-id'),
ec2InstanceCount: core.getInput('ec2-instance-count'),
ec2InstanceIds: JSON.parse(core.getInput('ec2-instance-ids')),
iamRoleName: core.getInput('iam-role-name'),
runnerHomeDir: core.getInput('runner-home-dir'),
preRunnerScript: core.getInput('pre-runner-script'),
Expand All @@ -20,7 +21,10 @@ class Config {
const tags = JSON.parse(core.getInput('aws-resource-tags'));
this.tagSpecifications = null;
if (tags.length > 0) {
this.tagSpecifications = [{ResourceType: 'instance', Tags: tags}, {ResourceType: 'volume', Tags: tags}];
this.tagSpecifications = [
{ ResourceType: 'instance', Tags: tags },
{ ResourceType: 'volume', Tags: tags },
];
}

// the values of github.context.repo.owner and github.context.repo.repo are taken from
Expand Down Expand Up @@ -48,7 +52,7 @@ class Config {
throw new Error(`Not all the required inputs are provided for the 'start' mode`);
}
} else if (this.input.mode === 'stop') {
if (!this.input.label || !this.input.ec2InstanceId) {
if (!this.input.label || !this.input.ec2InstanceIds) {
throw new Error(`Not all the required inputs are provided for the 'stop' mode`);
}
} else {
Expand Down
41 changes: 24 additions & 17 deletions src/gh.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ const config = require('./config');

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

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

async function removeRunner() {
const runner = await getRunner(config.input.label);
const runners = await getRunners(config.input.label);
const octokit = github.getOctokit(config.input.githubToken);

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

try {
await octokit.request('DELETE /repos/{owner}/{repo}/actions/runners/{runner_id}', _.merge(config.githubContext, { runner_id: runner.id }));
core.info(`GitHub self-hosted runner ${runner.name} is removed`);
return;
} catch (error) {
core.error('GitHub self-hosted runner removal error');
throw error;
const errors = runners.reduce(async (errors, runner) => {
try {
await octokit.request('DELETE /repos/{owner}/{repo}/actions/runners/{runner_id}', _.merge(config.githubContext, { runner_id: runner.id }));
core.info(`GitHub self-hosted runner ${runner.name} is removed`);
} catch (error) {
core.error(`GitHub self-hosted runner ${runner} removal error: ${error}`);
errors.push(error);
}
return errors;
});
if (errors.length > 0) {
core.setFailure('Encountered error(s) removing self-hosted runner(s)');
}
}

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

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

return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
const runner = await getRunner(label);
const runners = await getRunners(label);

if (waitSeconds > timeoutMinutes * 60) {
core.error('GitHub self-hosted runner registration error');
clearInterval(interval);
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.`);
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.`
);
}

if (runner && runner.status === 'online') {
core.info(`GitHub self-hosted runner ${runner.name} is registered and ready to use`);
if (runners && runners.every((runner) => runner.status === 'online')) {
core.info(`GitHub self-hosted runners ${runners} are registered and ready to use`);
clearInterval(interval);
resolve();
} else {
Expand Down
10 changes: 5 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ const gh = require('./gh');
const config = require('./config');
const core = require('@actions/core');

function setOutput(label, ec2InstanceId) {
function setOutput(label, ec2InstanceIds) {
core.setOutput('label', label);
core.setOutput('ec2-instance-id', ec2InstanceId);
core.setOutput('ec2-instance-ids', ec2InstanceIds);
}

async function start() {
const label = config.generateUniqueLabel();
const githubRegistrationToken = await gh.getRegistrationToken();
const ec2InstanceId = await aws.startEc2Instance(label, githubRegistrationToken);
setOutput(label, ec2InstanceId);
await aws.waitForInstanceRunning(ec2InstanceId);
const ec2InstanceIds = await aws.startEc2Instance(label, githubRegistrationToken);
setOutput(label, ec2InstanceIds);
await aws.waitForInstanceRunning(ec2InstanceIds);
await gh.waitForRunnerRegistered(label);
}

Expand Down

0 comments on commit 707d566

Please sign in to comment.