Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for multiple runners #82

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
16 changes: 11 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,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.
The id is used to terminate the EC2 instance when the runner is not needed anymore.
EC2 Instance Id(s) of the created runner(s).
The Id(s) are used to terminate the EC2 instance(s) 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 All @@ -65,16 +66,21 @@ inputs:
description: >-
Directory that contains actions-runner software and scripts. E.g. /home/runner/actions-runner.
required: false
runner-count:
description: >-
Number of instances to create.
required: false
default: '1'
outputs:
label:
description: >-
Name of the unique label assigned to the runner.
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:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the name change here is considered breaking api-wise, we could always revert this and still treat it as one or more. I just thought it was more clear to make it plural.

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: node12
Expand Down
28 changes: 14 additions & 14 deletions src/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ function buildUserDataScript(githubRegistrationToken, label) {
'#!/bin/bash',
'mkdir actions-runner && cd actions-runner',
'case $(uname -m) in aarch64) ARCH="arm64" ;; amd64|x86_64) ARCH="x64" ;; esac && export RUNNER_ARCH=${ARCH}',
'curl -O -L https://github.com/actions/runner/releases/download/v2.280.3/actions-runner-linux-${RUNNER_ARCH}-2.280.3.tar.gz',
'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-2.280.3.tar.gz',
'curl -O -L https://github.com/actions/runner/releases/download/v2.284.0/actions-runner-linux-${RUNNER_ARCH}-2.284.0.tar.gz',
'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-2.284.0.tar.gz',
Comment on lines +23 to +24
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the version update here is not required, could be pulled out - would still be nice for this to just be automatically the most recent release (#16)

'export RUNNER_ALLOW_RUNASROOT=1',
'export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1',
`./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${label}`,
Expand All @@ -38,8 +38,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
const params = {
ImageId: config.input.ec2ImageId,
InstanceType: config.input.ec2InstanceType,
MinCount: 1,
MaxCount: 1,
MinCount: config.input.runnerCount,
MaxCount: config.input.runnerCount,
UserData: Buffer.from(userData.join('\n')).toString('base64'),
SubnetId: config.input.subnetId,
SecurityGroupIds: [config.input.securityGroupId],
Expand All @@ -49,9 +49,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 @@ -62,32 +62,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 instances ${config.input.ec2InstanceIds} are terminated`);
return;
} catch (error) {
core.error(`AWS EC2 instance ${config.input.ec2InstanceId} termination error`);
core.error(`AWS EC2 instances ${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
5 changes: 3 additions & 2 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ class Config {
subnetId: core.getInput('subnet-id'),
securityGroupId: core.getInput('security-group-id'),
label: core.getInput('label'),
ec2InstanceId: core.getInput('ec2-instance-id'),
ec2InstanceIds: JSON.parse(core.getInput('ec2-instance-ids')),
iamRoleName: core.getInput('iam-role-name'),
runnerHomeDir: core.getInput('runner-home-dir'),
runnerCount: core.getInput('runner-count'),
};

const tags = JSON.parse(core.getInput('aws-resource-tags'));
Expand Down Expand Up @@ -47,7 +48,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
38 changes: 22 additions & 16 deletions src/gh.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ async function getRunner(label) {
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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is worth a bit of discussion. Technically only 1 of N runners may be ready at this point. Do we want to wait for all the expected runners, or can we let the workflow proceed even if some runners may not yet be ready? I'm more inclined to let it proceed even if not all runners come online. It will probably take care of itself, and if not, it will just run slower than expected.

} catch (error) {
return null;
}
Expand All @@ -32,29 +32,34 @@ async function getRegistrationToken() {
}

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

// skip the runner removal process if the runner is not found
if (!runner) {
if (!runners) {
core.info(`GitHub self-hosted runner with label ${config.input.label} is 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 = [];
for (const runner of runners) {
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);
}
}
if (errors.length > 0) {
core.setFailed('Failures occurred when removing runners.');
}
}

async function waitForRunnerRegistered(label) {
const timeoutMinutes = 5;
const retryIntervalSeconds = 10;
const quietPeriodSeconds = 30;
const timeoutMinutes = 8;
const retryIntervalSeconds = 20;
const quietPeriodSeconds = 40;
Comment on lines +60 to +62
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these changes are not intended to be merged - they were just for testing

let waitSeconds = 0;

core.info(`Waiting ${quietPeriodSeconds}s for the AWS EC2 instance to be registered in GitHub as a new self-hosted runner`);
Expand All @@ -63,22 +68,23 @@ async function waitForRunnerRegistered(label) {

return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
const runner = await getRunner(label);
const runners = await getRunner(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.`);
}

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 {
waitSeconds += retryIntervalSeconds;
core.info('Checking...');
}

}, retryIntervalSeconds * 1000);
});
}
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