Skip to content

Commit

Permalink
extended github action by adding multiple labels and multiple self-ho…
Browse files Browse the repository at this point in the history
…sted ec2 capability
  • Loading branch information
BrusR committed Oct 4, 2023
1 parent 2c4d1dc commit c110282
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 75 deletions.
21 changes: 0 additions & 21 deletions LICENSE

This file was deleted.

65 changes: 43 additions & 22 deletions src/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,29 @@ const AWS = require('aws-sdk');
const core = require('@actions/core');
const config = require('./config');

function separateArrayWithCommas(arr) {
if (!Array.isArray(arr)) {
return "Input is not an array";
}
// Use the join() method to concatenate array elements into a string
return arr.join(",");
}
function retrieveInstanceIDsFromArrayofMaps(arrayOfMaps) {
// Check if the input is an array
if (!Array.isArray(arrayOfMaps)) {
return "Input is not an array";
}

// Use map() to extract the "instanceID" from each map
const instanceIDs = arrayOfMaps.map((map) => map["InstanceId"]);

// Filter out undefined values in case some maps don't have "instanceID"
const filteredInstanceIDs = instanceIDs.filter((id) => id !== undefined);

return filteredInstanceIDs;
}
// User data scripts are run as the root user
function buildUserDataScript(githubRegistrationToken, label) {
function buildUserDataScript(githubRegistrationToken, labels) {
if (config.input.runnerHomeDir) {
// If runner home directory is specified, we expect the actions-runner software (and dependencies)
// to be pre-installed in the AMI, so we simply cd into that directory and then start the runner
Expand All @@ -13,7 +34,7 @@ function buildUserDataScript(githubRegistrationToken, label) {
`echo "${config.input.preRunnerScript}" > pre-runner-script.sh`,
'source pre-runner-script.sh',
'export RUNNER_ALLOW_RUNASROOT=1',
`./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${label}`,
`./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${separateArrayWithCommas(labels)}`,
'./run.sh',
];
} else {
Expand All @@ -26,22 +47,22 @@ function buildUserDataScript(githubRegistrationToken, label) {
'curl -O -L https://github.com/actions/runner/releases/download/v2.299.1/actions-runner-linux-${RUNNER_ARCH}-2.299.1.tar.gz',
'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-2.299.1.tar.gz',
'export RUNNER_ALLOW_RUNASROOT=1',
`./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${label}`,
`./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${labels_str}`,
'./run.sh',
];
}
}

async function startEc2Instance(label, githubRegistrationToken) {
async function startEc2Instances(labels, githubRegistrationToken) {
const ec2 = new AWS.EC2();

const userData = buildUserDataScript(githubRegistrationToken, label);
const userData = buildUserDataScript(githubRegistrationToken, labels);

const params = {
ImageId: config.input.ec2ImageId,
InstanceType: config.input.ec2InstanceType,
MinCount: 1,
MaxCount: 1,
MinCount: config.input.instanceQuantity,
MaxCount: config.input.instanceQuantity,
UserData: Buffer.from(userData.join('\n')).toString('base64'),
SubnetId: config.input.subnetId,
SecurityGroupIds: [config.input.securityGroupId],
Expand All @@ -51,51 +72,51 @@ 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 ec2InstancesIds = retrieveInstanceIDsFromArrayofMaps(result.Instances)
core.info(`AWS EC2 instances ${separateArrayWithCommas(ec2InstancesIds)} are started`);
return ec2InstancesIds;
} catch (error) {
core.error('AWS EC2 instance starting error');
core.error('AWS EC2 instances starting error');
throw error;
}
}

async function terminateEc2Instance() {
async function terminateEc2Instances() {
const ec2 = new AWS.EC2();

const params = {
InstanceIds: [config.input.ec2InstanceId],
InstanceIds: config.getec2InstanceIds(),
};

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

async function waitForInstanceRunning(ec2InstanceId) {
async function waitForInstancesRunning(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 instance ${separateArrayWithCommas(ec2InstanceIds)} are up and running`);
return;
} catch (error) {
core.error(`AWS EC2 instance ${ec2InstanceId} initialization error`);
core.error(`AWS EC2 instance ${separateArrayWithCommas(ec2InstanceIds)} initialization error`);
throw error;
}
}

module.exports = {
startEc2Instance,
terminateEc2Instance,
waitForInstanceRunning,
startEc2Instances,
terminateEc2Instances,
waitForInstancesRunning,
};
10 changes: 9 additions & 1 deletion src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ class Config {
subnetId: core.getInput('subnet-id'),
securityGroupId: core.getInput('security-group-id'),
label: core.getInput('label'),
ec2InstanceId: core.getInput('ec2-instance-id'),
labels: core.getInput('labels'),
ec2InstanceIds: core.getInput('ec2-instance-id'),
iamRoleName: core.getInput('iam-role-name'),
runnerHomeDir: core.getInput('runner-home-dir'),
preRunnerScript: core.getInput('pre-runner-script'),
instanceQuantity: core.getInput('instance-quantity')
};

const tags = JSON.parse(core.getInput('aws-resource-tags'));
Expand Down Expand Up @@ -59,6 +61,12 @@ class Config {
generateUniqueLabel() {
return Math.random().toString(36).substr(2, 5);
}
getLabels() {
return JSON.parse(this.labels)
}
getec2InstanceIds(){
return JSON.parse(this.ec2InstanceIds)
}
}

try {
Expand Down
78 changes: 57 additions & 21 deletions src/gh.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,40 @@ const github = require('@actions/github');
const _ = require('lodash');
const config = require('./config');


function separateArrayWithCommas(arr) {
if (!Array.isArray(arr)) {
return "Input is not an array";
}
// Use the join() method to concatenate array elements into a string
return arr.join(",");
}

function areRunnersOnline(runners){
if (runners){
result = true
runners.forEach((runner) => {
if (runner.status !== 'online'){
result = false
}
});
return result
} else {
return false
}
}
// 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(multiple_labels) {
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;
const foundRunners = await octokit.paginate('GET /repos/{owner}/{repo}/actions/runners', config.githubContext);
multiple_labels.forEach((label) => {
const foundRunners = _.filter(foundRunners, { labels: [{ name: label }] });
});

return foundRunners.length > 0 ? foundRunners : null;
} catch (error) {
return null;
}
Expand All @@ -30,49 +55,60 @@ async function getRegistrationToken() {
throw error;
}
}

async function removeRunner() {
const runner = await getRunner(config.input.label);
async function removeRunners() {
const runners = await getRunners(config.getLabels());
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`);
// skip the runner removal process if no runners are found
if (!runners || runners.length === 0) {
core.info(`No GitHub self-hosted runners with labels ${separateArrayWithCommas(config.getLabels())} found, so the removal is skipped`);
return;
}

// Use Promise.all to remove runners asynchronously
const removalPromises = runners.map(async (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(`Error removing GitHub self-hosted runner ${runner.name}`);
throw error;
}
});

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;
await Promise.all(removalPromises);
core.info('All GitHub self-hosted runners are removed');
} catch (error) {
core.error('GitHub self-hosted runner removal error');
core.error('Error removing some GitHub self-hosted runners');
throw error;
}
}

async function waitForRunnerRegistered(label) {
async function waitForRunnersRegistered(labels) {
const timeoutMinutes = 5;
const retryIntervalSeconds = 10;
const quietPeriodSeconds = 30;
let waitSeconds = 0;

core.info(`Waiting ${quietPeriodSeconds}s for the AWS EC2 instance to be registered in GitHub as a new self-hosted runner`);
core.info(`Waiting ${quietPeriodSeconds}s for the AWS EC2 instances to be registered in GitHub as a new self-hosted runner`);
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(labels);

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 (areRunnersOnline(runners)) {
runners.forEach((runner, index ) => {
core.info(`GitHub self-hosted runner number ${index}, ${runner.name}, is registered and ready to use`);
});
clearInterval(interval);
resolve();
} else {
Expand All @@ -85,6 +121,6 @@ async function waitForRunnerRegistered(label) {

module.exports = {
getRegistrationToken,
removeRunner,
waitForRunnerRegistered,
removeRunners,
waitForRunnersRegistered,
};
20 changes: 10 additions & 10 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@ const gh = require('./gh');
const config = require('./config');
const core = require('@actions/core');

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

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

async function stop() {
await aws.terminateEc2Instance();
await gh.removeRunner();
await aws.terminateEc2Instances();
await gh.removeRunners();
}

(async function () {
Expand Down

0 comments on commit c110282

Please sign in to comment.