diff --git a/README.md b/README.md index 79c48ffa..b6e67983 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ Now you're ready to go! | `mode` | Always required. | Specify here which mode you want to use:
- `start` - to start a new runner;
- `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. | @@ -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. diff --git a/action.yml b/action.yml index 3f4f12c2..d6596da2 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/dist/index.js b/dist/index.js index b0e1851a..ae6c61d0 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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); @@ -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], @@ -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 = []; @@ -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() { @@ -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'), @@ -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) { diff --git a/src/__tests__/jit.test.js b/src/__tests__/jit.test.js index c114d2fe..c651d9bc 100644 --- a/src/__tests__/jit.test.js +++ b/src/__tests__/jit.test.js @@ -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'; diff --git a/src/aws.js b/src/aws.js index effbfc0b..e24382e3 100644 --- a/src/aws.js +++ b/src/aws.js @@ -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); @@ -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], @@ -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 = []; @@ -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() { diff --git a/src/config.js b/src/config.js index 1892a123..21cb18bf 100644 --- a/src/config.js +++ b/src/config.js @@ -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'), @@ -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) {