Skip to content

Commit

Permalink
Enable spawning multiple EC2 instances in a single call (#4)
Browse files Browse the repository at this point in the history
* Enable spawning multiple EC2 instances in a single action call
  • Loading branch information
sawyerknoblich-ovation authored Dec 15, 2022
1 parent fb3a577 commit b4361d4
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 71 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,15 @@ Now you're ready to go!
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | Always required. | Specify here which mode you want to use: <br> - `start` - to start a new runner; <br> - `stop` - to stop the previously created runner. |
| `github-token` | Always required. | GitHub Personal Access Token with the `repo` scope assigned. |
| `ec2-image-id` | Required if you use the `start` mode. | EC2 Image Id (AMI). <br><br> The new runner will be launched from this image. <br><br> The action is compatible with Amazon Linux 2 images. |
| `ec2-image-id` | Required if you use the `start` mode. | EC2 Image Id (AMI). <br><br> The new runners will be launched from this image. <br><br> The action is compatible with Amazon Linux 2 images. |
| `ec2-instance-type` | Required if you use the `start` mode. | EC2 Instance Type. |
| `ec2-instance-count` | Number of EC2 instances to create, defaults to 1. | EC2 Instance Count. |
| `subnet-id` | Required if you use the `start` mode. | VPC Subnet Id. <br><br> The subnet should belong to the same VPC as the specified security group. |
| `security-group-id` | Required if you use the `start` mode. | EC2 Security Group Id. <br><br> The security group should belong to the same VPC as the specified subnet. <br><br> Only the outbound traffic for port 443 should be allowed. No inbound traffic is required. |
| `label` | Required if you use the `stop` mode. | Name of the unique label assigned to the runner. <br><br> The label is provided by the output of the action in the `start` mode. <br><br> The label is used to remove the runner from GitHub when the runner is not needed anymore. |
| `ec2-instance-id` | Required if you use the `stop` mode. | EC2 Instance Id of the created runner. <br><br> The id is provided by the output of the action in the `start` mode. <br><br> The id is used to terminate the EC2 instance when the runner is not needed anymore. |
| `iam-role-name` | Optional. Used only with the `start` mode. | IAM role name to attach to the created EC2 runner. <br><br> This allows the runner to have permissions to run additional actions within the AWS account, without having to manage additional GitHub secrets and AWS users. <br><br> Setting this requires additional AWS permissions for the role launching the instance (see above). |
| `aws-resource-tags` | Optional. Used only with the `start` mode. | Specifies tags to add to the EC2 instance and any attached storage. <br><br> This field is a stringified JSON array of tag objects, each containing a `Key` and `Value` field (see example below). <br><br> Setting this requires additional AWS permissions for the role launching the instance (see above). |
| `label` | Required if you use the `stop` mode. | Name of the unique label assigned to the runners. <br><br> The label is provided by the output of the action in the `start` mode. <br><br> The label is used to remove the runners from GitHub when the runners are not needed anymore. |
| `ec2-instance-id` | Required if you use the `stop` mode. | EC2 Instance Ids of the created runners. <br><br> The ids are provided by the output of the action in the `start` mode. <br><br> The ids are used to terminate the EC2 instances when the runners are not needed anymore. |
| `iam-role-name` | Optional. Used only with the `start` mode. | IAM role name to attach to the created EC2 runners. <br><br> This allows the runners to have permissions to run additional actions within the AWS account, without having to manage additional GitHub secrets and AWS users. <br><br> Setting this requires additional AWS permissions for the role launching the instances (see above). |
| `aws-resource-tags` | Optional. Used only with the `start` mode. | Specifies tags to add to the EC2 instances and any attached storage. <br><br> This field is a stringified JSON array of tag objects, each containing a `Key` and `Value` field (see example below). <br><br> Setting this requires additional AWS permissions for the role launching the instance (see above). |
| `runner-home-dir` | Optional. Used only with the `start` mode. | Specifies a directory where pre-installed actions-runner software and scripts are located.<br><br> |

### Environment variables
Expand Down
45 changes: 27 additions & 18 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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.
name: On-demand self-hosted AWS EC2 runners for GitHub Actions
description: GitHub Action for automatic creation and registration AWS EC2 instances as GitHub Actions self-hosted runners.
author: Volodymyr Machula
branding:
icon: 'box'
Expand All @@ -8,28 +8,33 @@ inputs:
mode:
description: >-
Specify here which mode you want to use:
- 'start' - to start a new runner;
- 'stop' - to stop the previously created runner.
- 'start' - to start new runners;
- 'stop' - to stop the previously created runners.
required: true
github-token:
description: >-
GitHub Personal Access Token with the 'repo' scope assigned.
required: true
key-pair-name:
description: >-
Key pair name to use when creating the runner instance.
Key pair name to use when creating the runner instances.
This input is required if you use the 'start' mode.
required: false
ec2-image-id:
description: >-
EC2 Image Id (AMI). The new runner will be launched from this image.
EC2 Image Id (AMI). The new runners will be launched from this image.
This input is required if you use the 'start' mode.
required: false
ec2-instance-type:
description: >-
EC2 Instance Type.
This input is required if you use the 'start' mode.
required: false
ec2-instance-count:
description: >-
Number of EC2 instances to create.
required: false
default: 1
subnet-id:
description: >-
VPC Subnet Id. The subnet should belong to the same VPC as the specified security group.
Expand All @@ -39,29 +44,32 @@ inputs:
description: >-
EC2 Security Group Id.
The security group should belong to the same VPC as the specified subnet.
The runner doesn't require any inbound traffic. However, outbound traffic should be allowed.
The runners don't require any inbound traffic. However, outbound traffic should be allowed.
This input is required if you use the 'start' mode.
required: false
label:
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.
Name of the unique label assigned to the runners.
The label is used to remove the runners from GitHub when the runners are not needed anymore.
This input is required if you use the 'stop' mode.
required: false
# This input's name is in the singular form for backwards compatibility
ec2-instance-id:
description: >-
EC2 Instance Id of the created runner.
The id is used to terminate the EC2 instance when the runner is not needed anymore.
This input is required if you use the 'stop' mode.
EC2 Instance Ids of the created runners.
The ids are used to terminate the EC2 instances when the runners are not needed anymore.
This input is required if you use the 'stop' mode. The value can either be in the form of
a single raw string containing a single EC2 instance id, or a JSON-encoded string representing
an array of id strings.
required: false
iam-role-name:
description: >-
IAM Role Name to attach to the created EC2 instance.
IAM Role Name to attach to the created EC2 instances.
This requires additional permissions on the AWS role used to launch instances.
required: false
aws-resource-tags:
description: >-
Tags to attach to the launched EC2 instance and volume.
Tags to attach to the launched EC2 instances and volumes.
This must be a stringified array of AWS Tag objects, with both Key and Value fields,
for example: '[{"Key": "TagKey1", "Value": "TagValue1"}, {"Key": "TagKey2", "Value": "TagValue2"}]'
required: false
Expand All @@ -73,14 +81,15 @@ inputs:
outputs:
label:
description: >-
Name of the unique label assigned to the runner.
Name of the unique label 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.
- to remove the runners from GitHub when they are not needed anymore.
# This output's name is in the singular form for backwards compatibility
ec2-instance-id:
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 Ids of the created runners.
The ids are used to terminate the EC2 instances when the runners are not needed anymore.
runs:
using: node16
main: ./dist/index.js
34 changes: 17 additions & 17 deletions src/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function buildUserDataScript(githubRegistrationToken, label) {
}
}

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

// User data scripts are run as the root user.
Expand All @@ -48,8 +48,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
const params = {
ImageId: config.input.ec2ImageId,
InstanceType: config.input.ec2InstanceType,
MinCount: 1,
MaxCount: 1,
MinCount: count,
MaxCount: count,
UserData: Buffer.from(userData.join('\n')).toString('base64'),
SubnetId: config.input.subnetId,
SecurityGroupIds: [config.input.securityGroupId],
Expand All @@ -60,51 +60,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 ec2InstanceIds = result.Instances.map(i => i.InstanceId);
core.info(`AWS EC2 instances ${JSON.stringify(ec2InstanceIds)} are started`);
return ec2InstanceIds;
} catch (error) {
core.error('AWS EC2 instance starting error');
throw error;
}
}

async function terminateEc2Instance() {
async function terminateEc2Instances() {
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 ${JSON.stringify(config.input.ec2InstanceIds)} are terminated`);
return;
} catch (error) {
core.error(`AWS EC2 instance ${config.input.ec2InstanceId} termination error`);
core.error(`AWS EC2 instances ${JSON.stringify(config.input.ec2InstanceIds)} 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 instances ${JSON.stringify(ec2InstanceIds)} are up and running`);
return;
} catch (error) {
core.error(`AWS EC2 instance ${ec2InstanceId} initialization error`);
core.error(`AWS EC2 instances ${JSON.stringify(ec2InstanceIds)} initialization error`);
throw error;
}
}

module.exports = {
startEc2Instance,
terminateEc2Instance,
waitForInstanceRunning,
startEc2Instances,
terminateEc2Instances,
waitForInstancesRunning,
};
24 changes: 22 additions & 2 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ class Config {
githubToken: core.getInput('github-token'),
ec2ImageId: core.getInput('ec2-image-id'),
ec2InstanceType: core.getInput('ec2-instance-type'),
ec2InstanceCount: core.getInput('ec2-instance-count'),
subnetId: core.getInput('subnet-id'),
securityGroupId: core.getInput('security-group-id'),
label: core.getInput('label'),
ec2InstanceId: core.getInput('ec2-instance-id'),
ec2InstanceIds: core.getInput('ec2-instance-id'),
iamRoleName: core.getInput('iam-role-name'),
keyPairName: core.getInput('key-pair-name'),
runnerHomeDir: core.getInput('runner-home-dir')
Expand Down Expand Up @@ -47,10 +48,29 @@ class Config {
if (!this.input.ec2ImageId || !this.input.ec2InstanceType || !this.input.subnetId || !this.input.securityGroupId || !this.input.keyPairName) {
throw new Error(`Not all the required inputs are provided for the 'start' mode`);
}

if (this.input.ec2InstanceCount === undefined) {
this.input.ec2InstanceCount = 1;
}
const parsedEc2InstanceCount = parseInt(this.input.ec2InstanceCount);
if (isNaN(parsedEc2InstanceCount)) {
throw new Error(`The 'ec2-instance-count' input has illegal value '${this.input.ec2InstanceCount}'`);
} else if (parsedEc2InstanceCount < 1) {
throw new Error(`The 'ec2-instance-count' input must be greater than zero`);
}
this.input.ec2InstanceCount = parsedEc2InstanceCount;
} 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`);
}

try {
const parsedEc2InstanceIds = JSON.parse(this.input.ec2InstanceIds);
this.input.ec2InstanceIds = parsedEc2InstanceIds;
} catch (error) {
core.info(`Got error ${error} when parsing '${this.input.ec2InstanceIds}' as JSON, assuming that it is a raw string containing a single EC2 instance ID`);
this.input.ec2InstanceIds = [this.input.ec2InstanceIds];
}
} else {
throw new Error('Wrong mode. Allowed values: start, stop.');
}
Expand Down
Loading

0 comments on commit b4361d4

Please sign in to comment.