diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a83ce42..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 Volodymyr Machula - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/aws.js b/src/aws.js index bcf5364..fd2b7c6 100644 --- a/src/aws.js +++ b/src/aws.js @@ -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 @@ -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 { @@ -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], @@ -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, }; diff --git a/src/config.js b/src/config.js index 1100f51..b65bd11 100644 --- a/src/config.js +++ b/src/config.js @@ -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')); @@ -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 { diff --git a/src/gh.js b/src/gh.js index abf9af9..2a799d4 100644 --- a/src/gh.js +++ b/src/gh.js @@ -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; } @@ -30,40 +55,49 @@ 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'); @@ -71,8 +105,10 @@ async function waitForRunnerRegistered(label) { 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 { @@ -85,6 +121,6 @@ async function waitForRunnerRegistered(label) { module.exports = { getRegistrationToken, - removeRunner, - waitForRunnerRegistered, + removeRunners, + waitForRunnersRegistered, }; diff --git a/src/index.js b/src/index.js index 00bc515..ca0f4d6 100644 --- a/src/index.js +++ b/src/index.js @@ -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 () {