diff --git a/README.md b/README.md
index dd419e47..b637b87a 100644
--- a/README.md
+++ b/README.md
@@ -310,6 +310,9 @@ Now you're ready to go!
| `ec2-instance-type` | Required if you use the `start` mode. | EC2 Instance Type.
Accepts a comma-separated **ordered fallback list** (e.g. `c7i.4xlarge,c6i.4xlarge,m7i.4xlarge`) — see [Capacity fallback](#capacity-fallback-across-azs-and-instance-types). A single value behaves as before. |
| `subnet-id` | Required if you use the `start` mode. | VPC Subnet Id.
The subnet should belong to the same VPC as the specified security group.
Accepts a comma-separated **ordered fallback list** of subnets (typically across AZs), e.g. `subnet-aaa,subnet-bbb`. |
| `security-group-id` | Required if you use the `start` mode. | EC2 Security Group Id.
The security group should belong to the same VPC as the specified subnet.
Only the outbound traffic for port 443 should be allowed. No inbound traffic is required. |
+| `market-type` | Optional. Used only with the `start` mode. | `on-demand` (default) or `spot`. Spot is typically 60–90% cheaper. See [Saving costs with spot](#saving-costs-with-spot). |
+| `spot-fallback` | Optional. Used only with `start` + `market-type: spot`. | What to do when spot capacity is unavailable: `on-demand` (default) retries the launch on-demand; `fail` surfaces the error. |
+| `spot-max-price` | Optional. Used only with `start` + `market-type: spot`. | Max spot price in USD/hour (e.g. `0.05`). Empty (default) caps at the on-demand price. |
| `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.
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.
The id is provided by the output of the action in the `start` mode.
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.
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.
Setting this requires additional AWS permissions for the role launching the instance (see above). |
@@ -347,6 +350,7 @@ We recommend using [aws-actions/configure-aws-credentials](https://github.com/aw
| `ec2-instance-id` | EC2 Instance Id of the created runner.
The id is used to terminate the EC2 instance when the runner is not needed anymore. |
| `instance-type-used` | The EC2 instance type actually launched. With a [capacity-fallback](#capacity-fallback-across-azs-and-instance-types) list this may differ from your first choice. |
| `subnet-id-used` | The subnet the runner was actually launched into. With a capacity-fallback list this may differ from your first choice. |
+| `market-type-used` | The market the runner launched in: `spot` or `on-demand`. Differs from `market-type` when spot fell back to on-demand. |
### Example
@@ -421,6 +425,25 @@ In [this discussion](https://github.com/machulav/ec2-github-runner/discussions/1
If you use this action in your workflow, feel free to add your story there as well 🙌
+## Saving costs with spot
+
+CI runners are a textbook spot workload — short-lived, ephemeral (registered with `--ephemeral`), and restartable — and spot pricing is typically 60–90% below on-demand. Opt in with `market-type: spot`:
+
+```yml
+- name: Start EC2 runner
+ uses: namecheap/ec2-github-runner@v3
+ with:
+ mode: start
+ # ... other inputs ...
+ market-type: spot
+ spot-fallback: on-demand # default — retry on-demand if spot is unavailable
+ # spot-max-price: '0.05' # optional cap; default is the on-demand price
+```
+
+The request is a **one-time** spot request with `InstanceInterruptionBehavior: terminate`, so nothing persistent is left to leak and `stop` mode terminates it identically to an on-demand instance.
+
+**Interruption trade-off:** a spot runner can be reclaimed mid-job with a 2-minute warning. Because runners register as `--ephemeral`, an interrupted runner auto-deregisters — the job fails visibly and re-runs cleanly rather than hanging. Prefer spot for retry-safe jobs. If spot capacity is unavailable at launch, `spot-fallback: on-demand` (the default) transparently launches on-demand instead; set `spot-fallback: fail` for cost-strict pipelines that must never pay on-demand. The `market-type-used` output reports which market actually launched. Spot composes with the [capacity fallback](#capacity-fallback-across-azs-and-instance-types) below: the whole type × subnet chain is tried on spot first, then again on-demand.
+
## Capacity fallback across AZs and instance types
A single `subnet-id` + `ec2-instance-type` means a single point of failure: when that AZ has no capacity for that type — routine for larger/GPU types — `RunInstances` fails and the whole workflow fails with it. Pass comma-separated ordered lists and the action walks them until a launch succeeds:
diff --git a/action.yml b/action.yml
index cafd86e6..60a3dccf 100644
--- a/action.yml
+++ b/action.yml
@@ -99,6 +99,27 @@ inputs:
this — opt in explicitly when you've verified the permissions.
required: false
default: 'false'
+ market-type:
+ description: >-
+ Used only with the 'start' mode. 'on-demand' (default) or 'spot'.
+ Spot instances are typically 60-90% cheaper and fit the ephemeral
+ runner model, but can be reclaimed mid-job (2-minute warning).
+ required: false
+ default: 'on-demand'
+ spot-fallback:
+ description: >-
+ Used only with the 'start' mode when market-type is 'spot'. What to
+ do when spot capacity is unavailable: 'on-demand' (default) retries
+ the launch as on-demand; 'fail' surfaces the error for cost-strict
+ pipelines.
+ required: false
+ default: 'on-demand'
+ spot-max-price:
+ description: >-
+ Used only with the 'start' mode when market-type is 'spot'. Optional
+ maximum spot price in USD/hour (e.g. '0.05'). Empty (default) uses the
+ on-demand price as the cap (AWS default).
+ required: false
volume-size:
description: >-
Used only with the 'start' mode. Root EBS volume size in GiB. When
@@ -206,6 +227,10 @@ outputs:
description: >-
The subnet the runner was actually launched into (start mode). With a
capacity-fallback list this may differ from the first choice.
+ market-type-used:
+ description: >-
+ The market the runner was actually launched in (start mode): 'spot' or
+ 'on-demand'. Differs from market-type when spot fell back to on-demand.
runs:
using: node24
main: ./dist/index.js
diff --git a/dist/index.js b/dist/index.js
index f5a3b2a9..7b3f58f9 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -104739,6 +104739,61 @@ async function launchWithFallback(attempt, instanceTypes, subnetIds) {
throw error;
}
+// Build the RunInstances InstanceMarketOptions for a spot launch, or
+// undefined for on-demand (so on-demand params are byte-identical to
+// before spot support). One-time requests fit the launch-use-terminate
+// lifecycle — no persistent spot request is left to leak — and the
+// interruption behavior is terminate to match the ephemeral runner model.
+function buildMarketOptions(marketType, spotMaxPrice) {
+ if (marketType !== 'spot') {
+ return undefined;
+ }
+ const spotOptions = {
+ SpotInstanceType: 'one-time',
+ InstanceInterruptionBehavior: 'terminate',
+ };
+ if (spotMaxPrice) {
+ spotOptions.MaxPrice = String(spotMaxPrice);
+ }
+ return { MarketType: 'spot', SpotOptions: spotOptions };
+}
+
+// The ordered list of market types to try. on-demand launches use just
+// ['on-demand']; spot launches try spot first and (unless spot-fallback is
+// 'fail') fall back to on-demand once spot capacity is exhausted.
+function buildMarketPlan(marketType, spotFallback) {
+ if (marketType !== 'spot') {
+ return ['on-demand'];
+ }
+ return spotFallback === 'fail' ? ['spot'] : ['spot', 'on-demand'];
+}
+
+// Run the capacity-fallback chain once per market in the plan. Spot is
+// tried across the whole type × subnet matrix first; only when that matrix
+// is exhausted by capacity/price errors do we downgrade to the next market
+// (on-demand). A fatal error inside a market aborts immediately without
+// downgrading. attemptFor(marketType) returns the per-cell attempt fn.
+async function launchAcrossMarkets(attemptFor, marketPlan, instanceTypes, subnetIds, hooks = {}) {
+ for (let i = 0; i < marketPlan.length; i++) {
+ const marketType = marketPlan[i];
+ try {
+ const placement = await launchWithFallback(attemptFor(marketType), instanceTypes, subnetIds);
+ return { ...placement, marketType };
+ } catch (error) {
+ const hasNextMarket = i < marketPlan.length - 1;
+ if (error.capacityExhausted && hasNextMarket) {
+ if (hooks.onDowngrade) {
+ hooks.onDowngrade(marketType, marketPlan[i + 1], error);
+ }
+ continue;
+ }
+ throw error;
+ }
+ }
+ /* istanbul ignore next — marketPlan is always non-empty */
+ throw new Error('no market attempt was made');
+}
+
// Instance tag the bootstrap script writes to phone home its progress.
// The start action polls it to fail fast on cloud-init errors instead of
// waiting out the full registration timeout. See buildUserData().
@@ -105144,8 +105199,14 @@ async function startEc2Instance(label, githubRegistrationToken) {
label,
});
- const attempt = async (instanceType, subnetId) => {
+ // For each market, build the per-cell attempt that injects the
+ // instance type, subnet, and (for spot) the market options.
+ const attemptFor = (marketType) => async (instanceType, subnetId) => {
const attemptParams = { ...params, InstanceType: instanceType, SubnetId: subnetId };
+ const marketOptions = buildMarketOptions(marketType, config.input.spotMaxPrice);
+ if (marketOptions) {
+ attemptParams.InstanceMarketOptions = marketOptions;
+ }
const result = await withRetry(
'run_instance',
() => client.send(new RunInstancesCommand(attemptParams)),
@@ -105154,18 +105215,24 @@ async function startEc2Instance(label, githubRegistrationToken) {
return result.Instances[0].InstanceId;
};
+ const marketPlan = buildMarketPlan(config.input.marketType, config.input.spotFallback);
let placement;
const runStart = Date.now();
try {
- placement = await launchWithFallback(attempt, instanceTypes, subnetIds);
+ placement = await launchAcrossMarkets(attemptFor, marketPlan, instanceTypes, subnetIds, {
+ onDowngrade: (from, to, error) => {
+ log.warn('spot_fallback', { from, to, reason: error.message });
+ core.warning(`Spot capacity unavailable — falling back to ${to}`);
+ },
+ });
} catch (error) {
log.error('run_instance', { error: error.name, message: error.message });
core.error('AWS EC2 instance starting error');
throw error;
}
const ec2InstanceId = placement.instanceId;
- log.info('run_instance', { instance_id: ec2InstanceId, instance_type: placement.instanceType, subnet_id: placement.subnetId, elapsed_ms: Date.now() - runStart });
- core.info(`AWS EC2 instance ${ec2InstanceId} is started (type ${placement.instanceType}, subnet ${placement.subnetId})`);
+ log.info('run_instance', { instance_id: ec2InstanceId, instance_type: placement.instanceType, subnet_id: placement.subnetId, market_type: placement.marketType, elapsed_ms: Date.now() - runStart });
+ core.info(`AWS EC2 instance ${ec2InstanceId} is started (type ${placement.instanceType}, subnet ${placement.subnetId}, ${placement.marketType})`);
if (config.input.eipAllocationId) {
await waitForInstanceRunning(ec2InstanceId);
@@ -105182,7 +105249,7 @@ async function startEc2Instance(label, githubRegistrationToken) {
}
}
- return { instanceId: ec2InstanceId, instanceType: placement.instanceType, subnetId: placement.subnetId };
+ return { instanceId: ec2InstanceId, instanceType: placement.instanceType, subnetId: placement.subnetId, marketType: placement.marketType };
}
async function terminateInstanceById(ec2InstanceId) {
@@ -105361,6 +105428,9 @@ module.exports = {
// Exported for unit testing.
classifyRunError,
launchWithFallback,
+ launchAcrossMarkets,
+ buildMarketOptions,
+ buildMarketPlan,
buildRootDeviceMapping,
wantsRootDeviceMapping,
buildVolumeOpts,
@@ -105536,6 +105606,9 @@ class Config {
ec2ImageId: core.getInput('ec2-image-id'),
ec2InstanceType: core.getInput('ec2-instance-type'),
subnetId: core.getInput('subnet-id'),
+ marketType: core.getInput('market-type') || 'on-demand',
+ spotFallback: core.getInput('spot-fallback') || 'on-demand',
+ spotMaxPrice: core.getInput('spot-max-price'),
securityGroupId: core.getInput('security-group-id'),
eipAllocationId: core.getInput('eip-allocation-id'),
label: core.getInput('label'),
@@ -105589,6 +105662,7 @@ class Config {
throw new Error(`Not all the required inputs for AMI search are provided for the 'start' mode`);
}
this.validateVolumeInputs();
+ this.validateMarketInputs();
} 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`);
@@ -105631,6 +105705,22 @@ class Config {
}
}
+ // Validate the spot/market inputs at config parse (fail fast before any
+ // AWS call). See src/aws.js buildMarketOptions/buildMarketPlan.
+ validateMarketInputs() {
+ const { marketType, spotFallback, spotMaxPrice } = this.input;
+ if (!['on-demand', 'spot'].includes(marketType)) {
+ throw new Error(`'market-type' must be one of: on-demand, spot`);
+ }
+ if (!['on-demand', 'fail'].includes(spotFallback)) {
+ throw new Error(`'spot-fallback' must be one of: on-demand, fail`);
+ }
+ // Positive decimal string, e.g. "0.05" or "1". Empty = AWS default cap.
+ if (spotMaxPrice && !(/^[0-9]+(\.[0-9]+)?$/.test(spotMaxPrice) && Number(spotMaxPrice) > 0)) {
+ throw new Error(`'spot-max-price' must be a positive decimal (USD/hour), e.g. 0.05`);
+ }
+ }
+
generateUniqueLabel() {
return Math.random().toString(36).substr(2, 5);
}
@@ -110925,12 +111015,12 @@ const core = __nccwpck_require__(7484);
// command; GitHub runners now surface that as a warning. Bypass the
// legacy path — modern runners always set GITHUB_OUTPUT.
function setOutput(label, placement) {
- const { instanceId, instanceType, subnetId } = placement;
+ const { instanceId, instanceType, subnetId, marketType } = placement;
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
fs.appendFileSync(
outputFile,
- `label=${label}${os.EOL}ec2-instance-id=${instanceId}${os.EOL}instance-type-used=${instanceType}${os.EOL}subnet-id-used=${subnetId}${os.EOL}`,
+ `label=${label}${os.EOL}ec2-instance-id=${instanceId}${os.EOL}instance-type-used=${instanceType}${os.EOL}subnet-id-used=${subnetId}${os.EOL}market-type-used=${marketType}${os.EOL}`,
);
return;
}
@@ -110938,6 +111028,7 @@ function setOutput(label, placement) {
core.setOutput('ec2-instance-id', instanceId);
core.setOutput('instance-type-used', instanceType);
core.setOutput('subnet-id-used', subnetId);
+ core.setOutput('market-type-used', marketType);
}
async function start() {
diff --git a/src/aws.js b/src/aws.js
index 26a07a1d..07d8c49e 100644
--- a/src/aws.js
+++ b/src/aws.js
@@ -89,6 +89,61 @@ async function launchWithFallback(attempt, instanceTypes, subnetIds) {
throw error;
}
+// Build the RunInstances InstanceMarketOptions for a spot launch, or
+// undefined for on-demand (so on-demand params are byte-identical to
+// before spot support). One-time requests fit the launch-use-terminate
+// lifecycle — no persistent spot request is left to leak — and the
+// interruption behavior is terminate to match the ephemeral runner model.
+function buildMarketOptions(marketType, spotMaxPrice) {
+ if (marketType !== 'spot') {
+ return undefined;
+ }
+ const spotOptions = {
+ SpotInstanceType: 'one-time',
+ InstanceInterruptionBehavior: 'terminate',
+ };
+ if (spotMaxPrice) {
+ spotOptions.MaxPrice = String(spotMaxPrice);
+ }
+ return { MarketType: 'spot', SpotOptions: spotOptions };
+}
+
+// The ordered list of market types to try. on-demand launches use just
+// ['on-demand']; spot launches try spot first and (unless spot-fallback is
+// 'fail') fall back to on-demand once spot capacity is exhausted.
+function buildMarketPlan(marketType, spotFallback) {
+ if (marketType !== 'spot') {
+ return ['on-demand'];
+ }
+ return spotFallback === 'fail' ? ['spot'] : ['spot', 'on-demand'];
+}
+
+// Run the capacity-fallback chain once per market in the plan. Spot is
+// tried across the whole type × subnet matrix first; only when that matrix
+// is exhausted by capacity/price errors do we downgrade to the next market
+// (on-demand). A fatal error inside a market aborts immediately without
+// downgrading. attemptFor(marketType) returns the per-cell attempt fn.
+async function launchAcrossMarkets(attemptFor, marketPlan, instanceTypes, subnetIds, hooks = {}) {
+ for (let i = 0; i < marketPlan.length; i++) {
+ const marketType = marketPlan[i];
+ try {
+ const placement = await launchWithFallback(attemptFor(marketType), instanceTypes, subnetIds);
+ return { ...placement, marketType };
+ } catch (error) {
+ const hasNextMarket = i < marketPlan.length - 1;
+ if (error.capacityExhausted && hasNextMarket) {
+ if (hooks.onDowngrade) {
+ hooks.onDowngrade(marketType, marketPlan[i + 1], error);
+ }
+ continue;
+ }
+ throw error;
+ }
+ }
+ /* istanbul ignore next — marketPlan is always non-empty */
+ throw new Error('no market attempt was made');
+}
+
// Instance tag the bootstrap script writes to phone home its progress.
// The start action polls it to fail fast on cloud-init errors instead of
// waiting out the full registration timeout. See buildUserData().
@@ -494,8 +549,14 @@ async function startEc2Instance(label, githubRegistrationToken) {
label,
});
- const attempt = async (instanceType, subnetId) => {
+ // For each market, build the per-cell attempt that injects the
+ // instance type, subnet, and (for spot) the market options.
+ const attemptFor = (marketType) => async (instanceType, subnetId) => {
const attemptParams = { ...params, InstanceType: instanceType, SubnetId: subnetId };
+ const marketOptions = buildMarketOptions(marketType, config.input.spotMaxPrice);
+ if (marketOptions) {
+ attemptParams.InstanceMarketOptions = marketOptions;
+ }
const result = await withRetry(
'run_instance',
() => client.send(new RunInstancesCommand(attemptParams)),
@@ -504,18 +565,24 @@ async function startEc2Instance(label, githubRegistrationToken) {
return result.Instances[0].InstanceId;
};
+ const marketPlan = buildMarketPlan(config.input.marketType, config.input.spotFallback);
let placement;
const runStart = Date.now();
try {
- placement = await launchWithFallback(attempt, instanceTypes, subnetIds);
+ placement = await launchAcrossMarkets(attemptFor, marketPlan, instanceTypes, subnetIds, {
+ onDowngrade: (from, to, error) => {
+ log.warn('spot_fallback', { from, to, reason: error.message });
+ core.warning(`Spot capacity unavailable — falling back to ${to}`);
+ },
+ });
} catch (error) {
log.error('run_instance', { error: error.name, message: error.message });
core.error('AWS EC2 instance starting error');
throw error;
}
const ec2InstanceId = placement.instanceId;
- log.info('run_instance', { instance_id: ec2InstanceId, instance_type: placement.instanceType, subnet_id: placement.subnetId, elapsed_ms: Date.now() - runStart });
- core.info(`AWS EC2 instance ${ec2InstanceId} is started (type ${placement.instanceType}, subnet ${placement.subnetId})`);
+ log.info('run_instance', { instance_id: ec2InstanceId, instance_type: placement.instanceType, subnet_id: placement.subnetId, market_type: placement.marketType, elapsed_ms: Date.now() - runStart });
+ core.info(`AWS EC2 instance ${ec2InstanceId} is started (type ${placement.instanceType}, subnet ${placement.subnetId}, ${placement.marketType})`);
if (config.input.eipAllocationId) {
await waitForInstanceRunning(ec2InstanceId);
@@ -532,7 +599,7 @@ async function startEc2Instance(label, githubRegistrationToken) {
}
}
- return { instanceId: ec2InstanceId, instanceType: placement.instanceType, subnetId: placement.subnetId };
+ return { instanceId: ec2InstanceId, instanceType: placement.instanceType, subnetId: placement.subnetId, marketType: placement.marketType };
}
async function terminateInstanceById(ec2InstanceId) {
@@ -711,6 +778,9 @@ module.exports = {
// Exported for unit testing.
classifyRunError,
launchWithFallback,
+ launchAcrossMarkets,
+ buildMarketOptions,
+ buildMarketPlan,
buildRootDeviceMapping,
wantsRootDeviceMapping,
buildVolumeOpts,
diff --git a/src/config.js b/src/config.js
index cebfe4ce..9be09e83 100644
--- a/src/config.js
+++ b/src/config.js
@@ -11,6 +11,9 @@ class Config {
ec2ImageId: core.getInput('ec2-image-id'),
ec2InstanceType: core.getInput('ec2-instance-type'),
subnetId: core.getInput('subnet-id'),
+ marketType: core.getInput('market-type') || 'on-demand',
+ spotFallback: core.getInput('spot-fallback') || 'on-demand',
+ spotMaxPrice: core.getInput('spot-max-price'),
securityGroupId: core.getInput('security-group-id'),
eipAllocationId: core.getInput('eip-allocation-id'),
label: core.getInput('label'),
@@ -64,6 +67,7 @@ class Config {
throw new Error(`Not all the required inputs for AMI search are provided for the 'start' mode`);
}
this.validateVolumeInputs();
+ this.validateMarketInputs();
} 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`);
@@ -106,6 +110,22 @@ class Config {
}
}
+ // Validate the spot/market inputs at config parse (fail fast before any
+ // AWS call). See src/aws.js buildMarketOptions/buildMarketPlan.
+ validateMarketInputs() {
+ const { marketType, spotFallback, spotMaxPrice } = this.input;
+ if (!['on-demand', 'spot'].includes(marketType)) {
+ throw new Error(`'market-type' must be one of: on-demand, spot`);
+ }
+ if (!['on-demand', 'fail'].includes(spotFallback)) {
+ throw new Error(`'spot-fallback' must be one of: on-demand, fail`);
+ }
+ // Positive decimal string, e.g. "0.05" or "1". Empty = AWS default cap.
+ if (spotMaxPrice && !(/^[0-9]+(\.[0-9]+)?$/.test(spotMaxPrice) && Number(spotMaxPrice) > 0)) {
+ throw new Error(`'spot-max-price' must be a positive decimal (USD/hour), e.g. 0.05`);
+ }
+ }
+
generateUniqueLabel() {
return Math.random().toString(36).substr(2, 5);
}
diff --git a/src/index.js b/src/index.js
index 81eb4e7e..31c5df3d 100644
--- a/src/index.js
+++ b/src/index.js
@@ -28,12 +28,12 @@ const core = require('@actions/core');
// command; GitHub runners now surface that as a warning. Bypass the
// legacy path — modern runners always set GITHUB_OUTPUT.
function setOutput(label, placement) {
- const { instanceId, instanceType, subnetId } = placement;
+ const { instanceId, instanceType, subnetId, marketType } = placement;
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
fs.appendFileSync(
outputFile,
- `label=${label}${os.EOL}ec2-instance-id=${instanceId}${os.EOL}instance-type-used=${instanceType}${os.EOL}subnet-id-used=${subnetId}${os.EOL}`,
+ `label=${label}${os.EOL}ec2-instance-id=${instanceId}${os.EOL}instance-type-used=${instanceType}${os.EOL}subnet-id-used=${subnetId}${os.EOL}market-type-used=${marketType}${os.EOL}`,
);
return;
}
@@ -41,6 +41,7 @@ function setOutput(label, placement) {
core.setOutput('ec2-instance-id', instanceId);
core.setOutput('instance-type-used', instanceType);
core.setOutput('subnet-id-used', subnetId);
+ core.setOutput('market-type-used', marketType);
}
async function start() {
diff --git a/tests/config.test.js b/tests/config.test.js
index 4a3e6e43..e1373b95 100644
--- a/tests/config.test.js
+++ b/tests/config.test.js
@@ -192,6 +192,39 @@ describe('Config — root volume inputs', () => {
});
});
+describe('Config — spot / market inputs', () => {
+ test('defaults to on-demand with default fallback', () => {
+ const config = loadConfig(startModeInputs);
+ expect(config.input.marketType).toBe('on-demand');
+ expect(config.input.spotFallback).toBe('on-demand');
+ expect(config.input.spotMaxPrice).toBe('');
+ });
+
+ test('accepts a valid spot configuration', () => {
+ const config = loadConfig({ ...startModeInputs, 'market-type': 'spot', 'spot-fallback': 'fail', 'spot-max-price': '0.05' });
+ expect(config.input.marketType).toBe('spot');
+ expect(config.input.spotFallback).toBe('fail');
+ expect(config.input.spotMaxPrice).toBe('0.05');
+ });
+
+ test('rejects an invalid market-type', () => {
+ expectValidationFailure({ ...startModeInputs, 'market-type': 'reserved' }, /'market-type' must be one of/);
+ });
+
+ test('rejects an invalid spot-fallback', () => {
+ expectValidationFailure({ ...startModeInputs, 'market-type': 'spot', 'spot-fallback': 'retry' }, /'spot-fallback' must be one of/);
+ });
+
+ test('rejects a non-numeric spot-max-price', () => {
+ expectValidationFailure({ ...startModeInputs, 'market-type': 'spot', 'spot-max-price': 'cheap' }, /'spot-max-price' must be a positive decimal/);
+ });
+
+ test('accepts a decimal spot-max-price', () => {
+ const config = loadConfig({ ...startModeInputs, 'market-type': 'spot', 'spot-max-price': '1.5' });
+ expect(config.input.spotMaxPrice).toBe('1.5');
+ });
+});
+
describe('Config — max-lifetime-minutes input', () => {
test('defaults to 360 when unset', () => {
const config = loadConfig(startModeInputs);
diff --git a/tests/spot.test.js b/tests/spot.test.js
new file mode 100644
index 00000000..e03c78ef
--- /dev/null
+++ b/tests/spot.test.js
@@ -0,0 +1,93 @@
+// Tests for spot support: buildMarketOptions, buildMarketPlan, and the
+// launchAcrossMarkets orchestration (spot → on-demand fallback). The
+// per-market attempt fns are injected so the fallback logic is tested
+// without the AWS SDK. config + core are mocked (aws.js reaches log.js).
+jest.mock('../src/config', () => ({
+ input: { mode: 'start', debug: 'false' },
+ githubContext: { owner: 'o', repo: 'r' },
+}));
+jest.mock('@actions/core', () => ({
+ info: jest.fn(), warning: jest.fn(), error: jest.fn(), setFailed: jest.fn(), getInput: jest.fn(),
+ startGroup: jest.fn(), endGroup: jest.fn(),
+}));
+
+const { buildMarketOptions, buildMarketPlan, launchAcrossMarkets } = require('../src/aws');
+
+const err = (name) => Object.assign(new Error(name), { name });
+const capacity = () => err('InsufficientInstanceCapacity');
+
+describe('buildMarketOptions', () => {
+ test('returns undefined for on-demand (zero param diff)', () => {
+ expect(buildMarketOptions('on-demand')).toBeUndefined();
+ expect(buildMarketOptions('on-demand', '0.05')).toBeUndefined();
+ });
+
+ test('builds a one-time, terminate-on-interruption spot request', () => {
+ expect(buildMarketOptions('spot')).toEqual({
+ MarketType: 'spot',
+ SpotOptions: { SpotInstanceType: 'one-time', InstanceInterruptionBehavior: 'terminate' },
+ });
+ });
+
+ test('includes MaxPrice only when provided', () => {
+ expect(buildMarketOptions('spot', '0.05').SpotOptions.MaxPrice).toBe('0.05');
+ expect(buildMarketOptions('spot', '').SpotOptions.MaxPrice).toBeUndefined();
+ });
+});
+
+describe('buildMarketPlan', () => {
+ test('on-demand → only on-demand', () => {
+ expect(buildMarketPlan('on-demand', 'on-demand')).toEqual(['on-demand']);
+ });
+ test('spot with default fallback → spot then on-demand', () => {
+ expect(buildMarketPlan('spot', 'on-demand')).toEqual(['spot', 'on-demand']);
+ });
+ test('spot with fail fallback → spot only', () => {
+ expect(buildMarketPlan('spot', 'fail')).toEqual(['spot']);
+ });
+});
+
+describe('launchAcrossMarkets', () => {
+ test('returns the spot placement when spot succeeds (no on-demand attempt)', async () => {
+ const attemptFor = jest.fn((market) => jest.fn().mockResolvedValue(`i-${market}`));
+ const result = await launchAcrossMarkets(attemptFor, ['spot', 'on-demand'], ['t1'], ['s1']);
+ expect(result).toMatchObject({ instanceId: 'i-spot', marketType: 'spot' });
+ expect(attemptFor).toHaveBeenCalledTimes(1); // on-demand attempt fn never built
+ });
+
+ test('falls back to on-demand exactly once when spot capacity is exhausted', async () => {
+ const spotAttempt = jest.fn().mockRejectedValue(capacity());
+ const onDemandAttempt = jest.fn().mockResolvedValue('i-od');
+ const attemptFor = (market) => (market === 'spot' ? spotAttempt : onDemandAttempt);
+ const onDowngrade = jest.fn();
+
+ const result = await launchAcrossMarkets(attemptFor, ['spot', 'on-demand'], ['t1'], ['s1'], { onDowngrade });
+
+ expect(result).toMatchObject({ instanceId: 'i-od', marketType: 'on-demand' });
+ expect(spotAttempt).toHaveBeenCalledTimes(1);
+ expect(onDemandAttempt).toHaveBeenCalledTimes(1);
+ expect(onDowngrade).toHaveBeenCalledWith('spot', 'on-demand', expect.anything());
+ });
+
+ test('falls back on SpotMaxPriceTooLow (price is a capacity-class code)', async () => {
+ const spotAttempt = jest.fn().mockRejectedValue(err('SpotMaxPriceTooLow'));
+ const onDemandAttempt = jest.fn().mockResolvedValue('i-od');
+ const attemptFor = (market) => (market === 'spot' ? spotAttempt : onDemandAttempt);
+ const result = await launchAcrossMarkets(attemptFor, ['spot', 'on-demand'], ['t1'], ['s1']);
+ expect(result.marketType).toBe('on-demand');
+ });
+
+ test('spot-fallback: fail propagates the capacity error (no on-demand)', async () => {
+ const spotAttempt = jest.fn().mockRejectedValue(capacity());
+ const attemptFor = () => spotAttempt;
+ await expect(launchAcrossMarkets(attemptFor, ['spot'], ['t1'], ['s1'])).rejects.toMatchObject({ capacityExhausted: true });
+ });
+
+ test('a fatal spot error aborts without falling back to on-demand', async () => {
+ const spotAttempt = jest.fn().mockRejectedValue(err('InvalidAMIID.NotFound'));
+ const onDemandAttempt = jest.fn().mockResolvedValue('i-od');
+ const attemptFor = (market) => (market === 'spot' ? spotAttempt : onDemandAttempt);
+ await expect(launchAcrossMarkets(attemptFor, ['spot', 'on-demand'], ['t1'], ['s1'])).rejects.toThrow('InvalidAMIID.NotFound');
+ expect(onDemandAttempt).not.toHaveBeenCalled();
+ });
+});