diff --git a/action.yml b/action.yml index 09fa959..c1528d5 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ name: On-demand self-hosted AWS EC2 runner for GitHub Actions description: GitHub Action for automatic creation and registration AWS EC2 instance as a GitHub Actions self-hosted runner. -author: Volodymyr Machula +author: Based on Volodymyr Machulav original action branding: icon: 'box' color: 'orange' @@ -37,13 +37,13 @@ inputs: The runner doesn't require any inbound traffic. However, outbound traffic should be allowed. This input is required if you use the 'start' mode. required: false - label: + labels: description: >- Name of the unique label assigned to the runner. 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: + required: true + ec2-instances-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. @@ -71,9 +71,9 @@ inputs: required: false outputs: - label: + labels: description: >- - Name of the unique label assigned to the runner. + Name of the labels assigned to the runners. 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. diff --git a/dist/index.js b/dist/index.js index 409180d..2079d42 100644 --- a/dist/index.js +++ b/dist/index.js @@ -62802,8 +62802,29 @@ const AWS = __webpack_require__(71786); const core = __webpack_require__(42186); const config = __webpack_require__(34570); +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 @@ -62813,7 +62834,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 { @@ -62826,22 +62847,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 ${separateArrayWithCommas(labels)}`, './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], @@ -62851,53 +62872,53 @@ 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, }; @@ -62918,11 +62939,12 @@ class Config { ec2InstanceType: core.getInput('ec2-instance-type'), 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-instances-ids'), 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')); @@ -62967,6 +62989,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 { @@ -62987,15 +63015,40 @@ const github = __webpack_require__(95438); const _ = __webpack_require__(90250); const config = __webpack_require__(34570); + +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){ + var 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; + var foundRunners = await octokit.paginate('GET /repos/{owner}/{repo}/actions/runners', config.githubContext); + multiple_labels.forEach((label) => { + foundRunners = _.filter(foundRunners, { labels: [{ name: label }] }); + }); + + return foundRunners.length > 0 ? foundRunners : null; } catch (error) { return null; } @@ -63014,40 +63067,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'); @@ -63055,8 +63117,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 { @@ -63069,8 +63133,8 @@ async function waitForRunnerRegistered(label) { module.exports = { getRegistrationToken, - removeRunner, - waitForRunnerRegistered, + removeRunners, + waitForRunnersRegistered, }; @@ -63084,23 +63148,23 @@ const gh = __webpack_require__(56989); const config = __webpack_require__(34570); const core = __webpack_require__(42186); -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-instances-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 () { @@ -63112,7 +63176,6 @@ async function stop() { } })(); - /***/ }), /***/ 22877: diff --git a/src/aws.js b/src/aws.js index fd2b7c6..e593e7c 100644 --- a/src/aws.js +++ b/src/aws.js @@ -47,7 +47,7 @@ function buildUserDataScript(githubRegistrationToken, labels) { '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 ${labels_str}`, + `./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${separateArrayWithCommas(labels)}`, './run.sh', ]; } diff --git a/src/config.js b/src/config.js index b65bd11..4b2621d 100644 --- a/src/config.js +++ b/src/config.js @@ -10,9 +10,8 @@ class Config { ec2InstanceType: core.getInput('ec2-instance-type'), subnetId: core.getInput('subnet-id'), securityGroupId: core.getInput('security-group-id'), - label: core.getInput('label'), labels: core.getInput('labels'), - ec2InstanceIds: core.getInput('ec2-instance-id'), + ec2InstanceIds: core.getInput('ec2-instances-ids'), iamRoleName: core.getInput('iam-role-name'), runnerHomeDir: core.getInput('runner-home-dir'), preRunnerScript: core.getInput('pre-runner-script'), diff --git a/src/gh.js b/src/gh.js index 2a799d4..3fab7ea 100644 --- a/src/gh.js +++ b/src/gh.js @@ -14,7 +14,7 @@ function separateArrayWithCommas(arr) { function areRunnersOnline(runners){ if (runners){ - result = true + var result = true runners.forEach((runner) => { if (runner.status !== 'online'){ result = false @@ -31,9 +31,9 @@ async function getRunners(multiple_labels) { const octokit = github.getOctokit(config.input.githubToken); try { - const foundRunners = await octokit.paginate('GET /repos/{owner}/{repo}/actions/runners', config.githubContext); + var foundRunners = await octokit.paginate('GET /repos/{owner}/{repo}/actions/runners', config.githubContext); multiple_labels.forEach((label) => { - const foundRunners = _.filter(foundRunners, { labels: [{ name: label }] }); + foundRunners = _.filter(foundRunners, { labels: [{ name: label }] }); }); return foundRunners.length > 0 ? foundRunners : null; diff --git a/src/index.js b/src/index.js index ca0f4d6..2d91684 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,7 @@ const core = require('@actions/core'); function setOutput(labels, ec2InstanceIds) { core.setOutput('labels', JSON.stringify(labels)); - core.setOutput('ec2-instance-ids', JSON.stringify(ec2InstanceIds)); + core.setOutput('ec2-instances-ids', JSON.stringify(ec2InstanceIds)); } async function start() { @@ -29,4 +29,4 @@ async function stop() { core.error(error); core.setFailed(error.message); } -})(); +})(); \ No newline at end of file