Skip to content

Commit

Permalink
feat: support for using spot instances (#210)
Browse files Browse the repository at this point in the history
* Update ecmaVersion to 2020

* Run prettier on src

* Add marketType input

* Specify InstanceMarketOptions

* Fix typo

* Generate dist

* Allow specifying market type
  • Loading branch information
tverghis authored Jan 22, 2025
1 parent 6a96fd4 commit 28fbe1c
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 43 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ env:
extends:
- eslint:recommended
parserOptions:
ecmaVersion: 2018
ecmaVersion: 2020
sourceType: module
rules:
no-use-before-define: error
Expand Down
9 changes: 7 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ inputs:
required: false
ec2-instance-type:
description: >-
EC2 Instance Type.
EC2 Instance Type.
This input is required if you use the 'start' mode.
required: false
subnet-id:
Expand All @@ -32,7 +32,7 @@ inputs:
required: false
security-group-id:
description: >-
EC2 Security Group Id.
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.
This input is required if you use the 'start' mode.
Expand Down Expand Up @@ -69,6 +69,11 @@ inputs:
description: >-
Specifies bash commands to run before the runner starts. It's useful for installing dependencies with apt-get, yum, dnf, etc.
required: false
market-type:
description: >-
Specifies the market (purchasing) option for the instance:
- 'spot' - Use a spot instance
required: false

outputs:
label:
Expand Down
64 changes: 44 additions & 20 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -148362,7 +148362,7 @@ function wrappy (fn, cb) {
/***/ 1150:
/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => {

const { EC2Client, RunInstancesCommand, TerminateInstancesCommand, waitUntilInstanceRunning } = __nccwpck_require__(3802);
const { EC2Client, RunInstancesCommand, TerminateInstancesCommand, waitUntilInstanceRunning } = __nccwpck_require__(3802);

const core = __nccwpck_require__(2186);
const config = __nccwpck_require__(4570);
Expand All @@ -148379,7 +148379,7 @@ function buildUserDataScript(githubRegistrationToken, label) {
'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}`,
'./run.sh'
'./run.sh',
];
} else {
return [
Expand All @@ -148392,11 +148392,24 @@ function buildUserDataScript(githubRegistrationToken, label) {
'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-2.313.0.tar.gz',
'export RUNNER_ALLOW_RUNASROOT=1',
`./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${label}`,
'./run.sh'
'./run.sh',
];
}
}

function buildMarketOptions() {
if (config.input.marketType !== 'spot') {
return undefined;
}

return {
MarketType: config.input.marketType,
SpotOptions: {
SpotInstanceType: 'one-time',
},
};
}

async function startEc2Instance(label, githubRegistrationToken) {
const ec2 = new EC2Client();

Expand All @@ -148411,7 +148424,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
SubnetId: config.input.subnetId,
UserData: Buffer.from(userData.join('\n')).toString('base64'),
IamInstanceProfile: { Name: config.input.iamRoleName },
TagSpecifications: config.tagSpecifications
TagSpecifications: config.tagSpecifications,
InstanceMarketOptions: buildMarketOptions(),
};

try {
Expand All @@ -148429,7 +148443,7 @@ async function terminateEc2Instance() {
const ec2 = new EC2Client();

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

try {
Expand All @@ -148445,21 +148459,21 @@ async function terminateEc2Instance() {
async function waitForInstanceRunning(ec2InstanceId) {
const ec2 = new EC2Client();
try {
core.info(`Cheking for instance ${ec2InstanceId} to be up and running`)
core.info(`Checking for instance ${ec2InstanceId} to be up and running`);
await waitUntilInstanceRunning(
{
client: ec2,
maxWaitTime: 300,
}, {
Filters: [
{
Name: 'instance-id',
Values: [
ec2InstanceId,
],
},
],
});
},
{
Filters: [
{
Name: 'instance-id',
Values: [ec2InstanceId],
},
],
},
);

core.info(`AWS EC2 instance ${ec2InstanceId} is up and running`);
return;
Expand All @@ -148472,7 +148486,7 @@ async function waitForInstanceRunning(ec2InstanceId) {
module.exports = {
startEc2Instance,
terminateEc2Instance,
waitForInstanceRunning
waitForInstanceRunning,
};


Expand All @@ -148498,12 +148512,16 @@ class Config {
iamRoleName: core.getInput('iam-role-name'),
runnerHomeDir: core.getInput('runner-home-dir'),
preRunnerScript: core.getInput('pre-runner-script'),
marketType: core.getInput('market-type'),
};

const tags = JSON.parse(core.getInput('aws-resource-tags'));
this.tagSpecifications = null;
if (tags.length > 0) {
this.tagSpecifications = [{ResourceType: 'instance', Tags: tags}, {ResourceType: 'volume', Tags: tags}];
this.tagSpecifications = [
{ ResourceType: 'instance', Tags: tags },
{ ResourceType: 'volume', Tags: tags },
];
}

// the values of github.context.repo.owner and github.context.repo.repo are taken from
Expand All @@ -148530,6 +148548,10 @@ class Config {
if (!this.input.ec2ImageId || !this.input.ec2InstanceType || !this.input.subnetId || !this.input.securityGroupId) {
throw new Error(`Not all the required inputs are provided for the 'start' mode`);
}

if (this.marketType?.length > 0 && this.input.marketType !== 'spot') {
throw new Error('Invalid `market-type` input. Allowed values: spot.');
}
} else if (this.input.mode === 'stop') {
if (!this.input.label || !this.input.ec2InstanceId) {
throw new Error(`Not all the required inputs are provided for the 'stop' mode`);
Expand Down Expand Up @@ -148617,7 +148639,7 @@ async function waitForRunnerRegistered(label) {
let waitSeconds = 0;

core.info(`Waiting ${quietPeriodSeconds}s for the AWS EC2 instance to be registered in GitHub as a new self-hosted runner`);
await new Promise(r => setTimeout(r, quietPeriodSeconds * 1000));
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) => {
Expand All @@ -148627,7 +148649,9 @@ async function waitForRunnerRegistered(label) {
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.`);
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') {
Expand Down
48 changes: 31 additions & 17 deletions src/aws.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { EC2Client, RunInstancesCommand, TerminateInstancesCommand, waitUntilInstanceRunning } = require('@aws-sdk/client-ec2');
const { EC2Client, RunInstancesCommand, TerminateInstancesCommand, waitUntilInstanceRunning } = require('@aws-sdk/client-ec2');

const core = require('@actions/core');
const config = require('./config');
Expand All @@ -15,7 +15,7 @@ function buildUserDataScript(githubRegistrationToken, label) {
'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}`,
'./run.sh'
'./run.sh',
];
} else {
return [
Expand All @@ -28,11 +28,24 @@ function buildUserDataScript(githubRegistrationToken, label) {
'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-2.313.0.tar.gz',
'export RUNNER_ALLOW_RUNASROOT=1',
`./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${label}`,
'./run.sh'
'./run.sh',
];
}
}

function buildMarketOptions() {
if (config.input.marketType !== 'spot') {
return undefined;
}

return {
MarketType: config.input.marketType,
SpotOptions: {
SpotInstanceType: 'one-time',
},
};
}

async function startEc2Instance(label, githubRegistrationToken) {
const ec2 = new EC2Client();

Expand All @@ -47,7 +60,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
SubnetId: config.input.subnetId,
UserData: Buffer.from(userData.join('\n')).toString('base64'),
IamInstanceProfile: { Name: config.input.iamRoleName },
TagSpecifications: config.tagSpecifications
TagSpecifications: config.tagSpecifications,
InstanceMarketOptions: buildMarketOptions(),
};

try {
Expand All @@ -65,7 +79,7 @@ async function terminateEc2Instance() {
const ec2 = new EC2Client();

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

try {
Expand All @@ -81,21 +95,21 @@ async function terminateEc2Instance() {
async function waitForInstanceRunning(ec2InstanceId) {
const ec2 = new EC2Client();
try {
core.info(`Cheking for instance ${ec2InstanceId} to be up and running`)
core.info(`Checking for instance ${ec2InstanceId} to be up and running`);
await waitUntilInstanceRunning(
{
client: ec2,
maxWaitTime: 300,
}, {
Filters: [
{
Name: 'instance-id',
Values: [
ec2InstanceId,
],
},
],
});
},
{
Filters: [
{
Name: 'instance-id',
Values: [ec2InstanceId],
},
],
},
);

core.info(`AWS EC2 instance ${ec2InstanceId} is up and running`);
return;
Expand All @@ -108,5 +122,5 @@ async function waitForInstanceRunning(ec2InstanceId) {
module.exports = {
startEc2Instance,
terminateEc2Instance,
waitForInstanceRunning
waitForInstanceRunning,
};
10 changes: 9 additions & 1 deletion src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ class Config {
iamRoleName: core.getInput('iam-role-name'),
runnerHomeDir: core.getInput('runner-home-dir'),
preRunnerScript: core.getInput('pre-runner-script'),
marketType: core.getInput('market-type'),
};

const tags = JSON.parse(core.getInput('aws-resource-tags'));
this.tagSpecifications = null;
if (tags.length > 0) {
this.tagSpecifications = [{ResourceType: 'instance', Tags: tags}, {ResourceType: 'volume', Tags: tags}];
this.tagSpecifications = [
{ ResourceType: 'instance', Tags: tags },
{ ResourceType: 'volume', Tags: tags },
];
}

// the values of github.context.repo.owner and github.context.repo.repo are taken from
Expand All @@ -47,6 +51,10 @@ class Config {
if (!this.input.ec2ImageId || !this.input.ec2InstanceType || !this.input.subnetId || !this.input.securityGroupId) {
throw new Error(`Not all the required inputs are provided for the 'start' mode`);
}

if (this.marketType?.length > 0 && this.input.marketType !== 'spot') {
throw new Error('Invalid `market-type` input. Allowed values: spot.');
}
} else if (this.input.mode === 'stop') {
if (!this.input.label || !this.input.ec2InstanceId) {
throw new Error(`Not all the required inputs are provided for the 'stop' mode`);
Expand Down
6 changes: 4 additions & 2 deletions src/gh.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ async function waitForRunnerRegistered(label) {
let waitSeconds = 0;

core.info(`Waiting ${quietPeriodSeconds}s for the AWS EC2 instance to be registered in GitHub as a new self-hosted runner`);
await new Promise(r => setTimeout(r, quietPeriodSeconds * 1000));
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) => {
Expand All @@ -68,7 +68,9 @@ async function waitForRunnerRegistered(label) {
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.`);
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') {
Expand Down

0 comments on commit 28fbe1c

Please sign in to comment.