Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ 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 and don't provide `availability-zones-config`. | EC2 Image Id (AMI). The new runner will be launched from this image. Compatible with Amazon Linux 2, Amazon Linux 2023, and Ubuntu images. |
| `ec2-instance-type` | Required if you use the `start` mode. | EC2 Instance Type. |
| `ec2-instance-type` | Required if you use the `start` mode. | EC2 Instance Type. Accepts a single type (e.g. `t3.micro`) or a JSON array of types (e.g. `'["t3.micro", "t3.small", "m5.large"]'`). When multiple types are specified, the action tries each in order until one succeeds. Useful for spot instances where capacity may vary by type. |
| `subnet-id` | Required if you use the `start` mode and don't provide `availability-zones-config`. | VPC Subnet Id. The subnet should belong to the same VPC as the specified security group. |
| `security-group-id` | Required if you use the `start` mode and don't provide `availability-zones-config`. | EC2 Security Group Id. The security group should belong to the same VPC as the specified subnet. Only outbound traffic for port 443 is required. No inbound traffic is required. |
| `label` | Required if you use the `stop` mode. | Name of the unique label assigned to the runner. The label is provided by the output of the action in the `start` mode. |
Expand Down Expand Up @@ -400,6 +400,26 @@ Each configuration object requires `imageId`, `subnetId`, and `securityGroupId`.
]
```

### Advanced: Multiple instance types

When using spot instances, a specific instance type may not have available capacity. By specifying multiple instance types as a JSON array, the action will try each type in order until one succeeds. This is especially powerful when combined with multi-AZ failover — the action tries all instance types within each AZ before moving to the next AZ.

```yml
- name: Start EC2 runner
id: start-ec2-runner
uses: machulav/ec2-github-runner@v2
with:
mode: start
github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
ec2-image-id: ami-123
ec2-instance-type: '["c5.xlarge", "c5a.xlarge", "c5d.xlarge", "m5.xlarge"]'
subnet-id: subnet-123
security-group-id: sg-123
market-type: spot
```

> **Tip:** Choose instance types with similar vCPU/memory specs so your workload runs consistently regardless of which type is selected.

### Advanced: Debug mode

When a runner fails to register, it can be difficult to diagnose the issue because user-data scripts execute on the remote EC2 instance. The `runner-debug` input enables verbose logging to help with troubleshooting.
Expand Down
5 changes: 4 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ inputs:
required: false
ec2-instance-type:
description: >-
EC2 Instance Type.
EC2 Instance Type. Accepts a single instance type (e.g. 't3.micro') or a JSON array
of instance types (e.g. '["t3.micro", "t3.small", "m5.large"]').
When multiple types are provided, the action tries each type in order until one succeeds.
This is especially useful with spot instances where a specific instance type may not have capacity.
This input is required if you use the 'start' mode.
required: false
subnet-id:
Expand Down
77 changes: 52 additions & 25 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -145221,7 +145221,7 @@ function buildMarketOptions() {
};
}

async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region, encodedJitConfig) {
async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region, encodedJitConfig, instanceType) {
// Region is always specified now, so we can directly use it
const ec2ClientOptions = { region };
const ec2 = new EC2Client(ec2ClientOptions);
Expand All @@ -145230,7 +145230,7 @@ async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, l

const params = {
ImageId: imageId,
InstanceType: config.input.ec2InstanceType,
InstanceType: instanceType,
MaxCount: 1,
MinCount: 1,
SecurityGroupIds: [securityGroupId],
Expand Down Expand Up @@ -145264,7 +145264,8 @@ async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, l
}

async function startEc2Instance(label, githubRegistrationToken, encodedJitConfig) {
core.info(`Attempting to start EC2 instance using ${config.availabilityZones.length} availability zone configuration(s)`);
const instanceTypes = config.input.ec2InstanceTypes;
core.info(`Attempting to start EC2 instance using ${config.availabilityZones.length} availability zone configuration(s) and ${instanceTypes.length} instance type(s): ${instanceTypes.join(', ')}`);

const errors = [];

Expand All @@ -145276,32 +145277,35 @@ async function startEc2Instance(label, githubRegistrationToken, encodedJitConfig
core.info(`Trying availability zone configuration ${i + 1}/${config.availabilityZones.length}`);
core.info(`Using imageId: ${azConfig.imageId}, subnetId: ${azConfig.subnetId}, securityGroupId: ${azConfig.securityGroupId}, region: ${region}`);

try {
const ec2InstanceId = await createEc2InstanceWithParams(
azConfig.imageId,
azConfig.subnetId,
azConfig.securityGroupId,
label,
githubRegistrationToken,
region,
encodedJitConfig
);

core.info(`Successfully started AWS EC2 instance ${ec2InstanceId} using availability zone configuration ${i + 1} in region ${region}`);
return { ec2InstanceId, region };
} catch (error) {
const errorMessage = `Failed to start EC2 instance with configuration ${i + 1} in region ${region}: ${error.message}`;
core.warning(errorMessage);
errors.push(errorMessage);
// Try each instance type within this AZ configuration
for (const instanceType of instanceTypes) {
core.info(`Trying instance type: ${instanceType}`);
try {
const ec2InstanceId = await createEc2InstanceWithParams(
azConfig.imageId,
azConfig.subnetId,
azConfig.securityGroupId,
label,
githubRegistrationToken,
region,
encodedJitConfig,
instanceType
);

// Continue to the next availability zone configuration
continue;
core.info(`Successfully started AWS EC2 instance ${ec2InstanceId} (type: ${instanceType}) using availability zone configuration ${i + 1} in region ${region}`);
return { ec2InstanceId, region };
} catch (error) {
const errorMessage = `Failed to start EC2 instance (type: ${instanceType}) with AZ configuration ${i + 1} in region ${region}: ${error.message}`;
core.warning(errorMessage);
errors.push(errorMessage);
continue;
}
}
}

// If we've tried all configurations and none worked, throw an error
core.error('All availability zone configurations failed');
throw new Error(`Failed to start EC2 instance in any availability zone. Errors: ${errors.join('; ')}`);
// If we've tried all configurations and instance types and none worked, throw an error
core.error('All availability zone configurations and instance types failed');
throw new Error(`Failed to start EC2 instance with any configuration. Errors: ${errors.join('; ')}`);
}

async function terminateEc2Instance() {
Expand Down Expand Up @@ -145409,6 +145413,7 @@ class Config {
ec2ImageId: core.getInput('ec2-image-id'),
ec2InstanceId: core.getInput('ec2-instance-id'),
ec2InstanceType: core.getInput('ec2-instance-type'),
ec2InstanceTypes: [],
githubToken: core.getInput('github-token'),
iamRoleName: core.getInput('iam-role-name'),
label: core.getInput('label'),
Expand Down Expand Up @@ -145506,6 +145511,28 @@ class Config {
throw new Error(`The 'ec2-instance-type' input is required for the 'start' mode.`);
}

// Parse ec2-instance-type: supports a single string or a JSON array of strings
const rawType = this.input.ec2InstanceType.trim();
if (rawType.startsWith('[')) {
try {
this.input.ec2InstanceTypes = JSON.parse(rawType);
if (!Array.isArray(this.input.ec2InstanceTypes) || this.input.ec2InstanceTypes.length === 0) {
throw new Error('must be a non-empty JSON array of strings');
}
for (const t of this.input.ec2InstanceTypes) {
if (typeof t !== 'string' || t.trim().length === 0) {
throw new Error('each element must be a non-empty string');
}
}
} catch (error) {
throw new Error(`Invalid 'ec2-instance-type' input: ${error.message}`);
}
} else {
this.input.ec2InstanceTypes = [rawType];
}
// Keep ec2InstanceType pointing to the first type for backward compatibility
this.input.ec2InstanceType = this.input.ec2InstanceTypes[0];

// If no availability zones config provided, check for individual parameters
if (this.availabilityZones.length === 0) {
if (!this.input.ec2ImageId || !this.input.subnetId || !this.input.securityGroupId) {
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/jit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,47 @@ describe('aws.js - runner-debug', () => {
});
});

describe('Config - ec2-instance-type parsing', () => {
beforeEach(() => {
process.env.AWS_REGION = 'us-east-1';
});

test('parses single instance type string into array', () => {
setupInputs({ 'ec2-instance-type': 't3.micro' });
const config = createConfig();
expect(config.input.ec2InstanceTypes).toEqual(['t3.micro']);
expect(config.input.ec2InstanceType).toBe('t3.micro');
});

test('parses JSON array of instance types', () => {
setupInputs({ 'ec2-instance-type': '["t3.micro", "t3.small", "m5.large"]' });
const config = createConfig();
expect(config.input.ec2InstanceTypes).toEqual(['t3.micro', 't3.small', 'm5.large']);
expect(config.input.ec2InstanceType).toBe('t3.micro');
});

test('throws on empty JSON array', () => {
setupInputs({ 'ec2-instance-type': '[]' });
expect(() => createConfig()).toThrow("Invalid 'ec2-instance-type' input");
});

test('throws on invalid JSON array content', () => {
setupInputs({ 'ec2-instance-type': '[123, 456]' });
expect(() => createConfig()).toThrow("Invalid 'ec2-instance-type' input");
});

test('throws on malformed JSON', () => {
setupInputs({ 'ec2-instance-type': '[not json' });
expect(() => createConfig()).toThrow("Invalid 'ec2-instance-type' input");
});

test('handles whitespace around single type', () => {
setupInputs({ 'ec2-instance-type': ' t3.micro ' });
const config = createConfig();
expect(config.input.ec2InstanceTypes).toEqual(['t3.micro']);
});
});

describe('Config - runner-debug input', () => {
beforeEach(() => {
process.env.AWS_REGION = 'us-east-1';
Expand Down
56 changes: 30 additions & 26 deletions src/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ function buildMarketOptions() {
};
}

async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region, encodedJitConfig) {
async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region, encodedJitConfig, instanceType) {
// Region is always specified now, so we can directly use it
const ec2ClientOptions = { region };
const ec2 = new EC2Client(ec2ClientOptions);
Expand All @@ -222,7 +222,7 @@ async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, l

const params = {
ImageId: imageId,
InstanceType: config.input.ec2InstanceType,
InstanceType: instanceType,
MaxCount: 1,
MinCount: 1,
SecurityGroupIds: [securityGroupId],
Expand Down Expand Up @@ -256,7 +256,8 @@ async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, l
}

async function startEc2Instance(label, githubRegistrationToken, encodedJitConfig) {
core.info(`Attempting to start EC2 instance using ${config.availabilityZones.length} availability zone configuration(s)`);
const instanceTypes = config.input.ec2InstanceTypes;
core.info(`Attempting to start EC2 instance using ${config.availabilityZones.length} availability zone configuration(s) and ${instanceTypes.length} instance type(s): ${instanceTypes.join(', ')}`);

const errors = [];

Expand All @@ -268,32 +269,35 @@ async function startEc2Instance(label, githubRegistrationToken, encodedJitConfig
core.info(`Trying availability zone configuration ${i + 1}/${config.availabilityZones.length}`);
core.info(`Using imageId: ${azConfig.imageId}, subnetId: ${azConfig.subnetId}, securityGroupId: ${azConfig.securityGroupId}, region: ${region}`);

try {
const ec2InstanceId = await createEc2InstanceWithParams(
azConfig.imageId,
azConfig.subnetId,
azConfig.securityGroupId,
label,
githubRegistrationToken,
region,
encodedJitConfig
);

core.info(`Successfully started AWS EC2 instance ${ec2InstanceId} using availability zone configuration ${i + 1} in region ${region}`);
return { ec2InstanceId, region };
} catch (error) {
const errorMessage = `Failed to start EC2 instance with configuration ${i + 1} in region ${region}: ${error.message}`;
core.warning(errorMessage);
errors.push(errorMessage);

// Continue to the next availability zone configuration
continue;
// Try each instance type within this AZ configuration
for (const instanceType of instanceTypes) {
core.info(`Trying instance type: ${instanceType}`);
try {
const ec2InstanceId = await createEc2InstanceWithParams(
azConfig.imageId,
azConfig.subnetId,
azConfig.securityGroupId,
label,
githubRegistrationToken,
region,
encodedJitConfig,
instanceType
);

core.info(`Successfully started AWS EC2 instance ${ec2InstanceId} (type: ${instanceType}) using availability zone configuration ${i + 1} in region ${region}`);
return { ec2InstanceId, region };
} catch (error) {
const errorMessage = `Failed to start EC2 instance (type: ${instanceType}) with AZ configuration ${i + 1} in region ${region}: ${error.message}`;
core.warning(errorMessage);
errors.push(errorMessage);
continue;
}
}
}

// If we've tried all configurations and none worked, throw an error
core.error('All availability zone configurations failed');
throw new Error(`Failed to start EC2 instance in any availability zone. Errors: ${errors.join('; ')}`);
// If we've tried all configurations and instance types and none worked, throw an error
core.error('All availability zone configurations and instance types failed');
throw new Error(`Failed to start EC2 instance with any configuration. Errors: ${errors.join('; ')}`);
}

async function terminateEc2Instance() {
Expand Down
23 changes: 23 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Config {
ec2ImageId: core.getInput('ec2-image-id'),
ec2InstanceId: core.getInput('ec2-instance-id'),
ec2InstanceType: core.getInput('ec2-instance-type'),
ec2InstanceTypes: [],
githubToken: core.getInput('github-token'),
iamRoleName: core.getInput('iam-role-name'),
label: core.getInput('label'),
Expand Down Expand Up @@ -104,6 +105,28 @@ class Config {
throw new Error(`The 'ec2-instance-type' input is required for the 'start' mode.`);
}

// Parse ec2-instance-type: supports a single string or a JSON array of strings
const rawType = this.input.ec2InstanceType.trim();
if (rawType.startsWith('[')) {
try {
this.input.ec2InstanceTypes = JSON.parse(rawType);
if (!Array.isArray(this.input.ec2InstanceTypes) || this.input.ec2InstanceTypes.length === 0) {
throw new Error('must be a non-empty JSON array of strings');
}
for (const t of this.input.ec2InstanceTypes) {
if (typeof t !== 'string' || t.trim().length === 0) {
throw new Error('each element must be a non-empty string');
}
}
} catch (error) {
throw new Error(`Invalid 'ec2-instance-type' input: ${error.message}`);
}
} else {
this.input.ec2InstanceTypes = [rawType];
}
// Keep ec2InstanceType pointing to the first type for backward compatibility
this.input.ec2InstanceType = this.input.ec2InstanceTypes[0];

// If no availability zones config provided, check for individual parameters
if (this.availabilityZones.length === 0) {
if (!this.input.ec2ImageId || !this.input.subnetId || !this.input.securityGroupId) {
Expand Down