diff --git a/action.yml b/action.yml index 0cea738b..30500186 100644 --- a/action.yml +++ b/action.yml @@ -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. @@ -65,6 +66,11 @@ 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: >- @@ -72,9 +78,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: node12 diff --git a/src/aws.js b/src/aws.js index 028dca42..7aca7758 100644 --- a/src/aws.js +++ b/src/aws.js @@ -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', '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}`, @@ -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], @@ -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; @@ -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; } } diff --git a/src/config.js b/src/config.js index 13bf86a1..fb858e42 100644 --- a/src/config.js +++ b/src/config.js @@ -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')); @@ -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 { diff --git a/src/gh.js b/src/gh.js index abf9af94..f444b5be 100644 --- a/src/gh.js +++ b/src/gh.js @@ -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; } catch (error) { return null; } @@ -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; let waitSeconds = 0; core.info(`Waiting ${quietPeriodSeconds}s for the AWS EC2 instance to be registered in GitHub as a new self-hosted runner`); @@ -63,7 +68,7 @@ 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'); @@ -71,14 +76,15 @@ 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 (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); }); } diff --git a/src/index.js b/src/index.js index 00bc5152..bb373ad9 100644 --- a/src/index.js +++ b/src/index.js @@ -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); }