Skip to content
Merged
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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,11 @@ Now you're ready to go!
| `eip-allocation-id` | Optional. Used only with the `start` mode. | Allocation Id of an Elastic IP to associate with the runner instance once it is running. |
| `runner-version` | Optional. Used only with the `start` mode. | Version of the `actions/runner` binary to download and register (default `2.335.1`). <br><br> Must have a matching entry in `src/runner-checksums.js`; the action verifies the downloaded tarball's SHA-256 against that table before extraction. |
| `http-tokens` | Optional. Used only with the `start` mode. | Instance Metadata Service (IMDS) token mode (default `required`). <br><br> - `required` — IMDSv2 only; mitigates SSRF-style credential theft. <br> - `optional` — also allows IMDSv1; set only if a workload on the runner needs it. |
| `encrypt-ebs` | Optional. Used only with the `start` mode. | When `true`, the root EBS volume is created with SSE-EBS encryption using the account's default AWS-managed key (default `false`). Volume size / type / IOPS are preserved from the AMI. |
| `encrypt-ebs` | Optional. Used only with the `start` mode. | When `true`, the root EBS volume is created with SSE-EBS encryption using the account's default AWS-managed key (default `false`). Volume size / type / IOPS are preserved from the AMI unless overridden by the `volume-*` inputs below. |
| `volume-size` | Optional. Used only with the `start` mode. | Root EBS volume size in GiB. Omitted = AMI default (Amazon Linux 2023: 8 GiB). Must be ≥ the AMI snapshot size. See [Disk space for Docker workloads](#disk-space-for-docker-workloads). |
| `volume-type` | Optional. Used only with the `start` mode. | Root EBS volume type: `gp3` (recommended), `gp2`, `io1`, or `io2`. Omitted = AMI default. |
| `volume-iops` | Optional. Used only with the `start` mode. | Provisioned IOPS for the root volume. Only valid with `volume-type` `io1`, `io2`, or `gp3`. |
| `volume-throughput` | Optional. Used only with the `start` mode. | Root volume throughput in MiB/s. Only valid with `volume-type` `gp3`. |
| `cleanup-on-start-failure` | Optional. Used only with the `start` mode. | When `true` (default), a runner that fails to bootstrap or register has its console output captured and is then terminated so the failed start doesn't leak a billing instance. Set `false` to leave the instance running for interactive debugging. <br><br> **Behavior change:** older versions left the instance running after a registration timeout; the default is now to terminate it. See [Troubleshooting a failed start](#troubleshooting-a-failed-start). |
| `max-lifetime-minutes` | Optional. Used only with the `start` mode. | Hard upper bound (minutes) on the instance's lifetime (default `360`). The instance arms a self-shutdown timer and launches with `InstanceInitiatedShutdownBehavior=terminate`, so it terminates itself at the TTL even if GitHub, the workflow, and AWS APIs are all unreachable. Size it **above your longest legitimate job** — a job still running at the TTL is killed. Set `0` to disable. See [Reaping orphaned runners](#reaping-orphaned-runners-mode-cleanup). |
| `max-age-minutes` | Optional. Used only with the `cleanup` mode. | A registered-but-idle runner instance older than this many minutes is reaped (default `120`). Instances whose runner is no longer registered are reaped regardless of age, subject to a 15-minute grace floor that protects in-flight starts. Busy runners are never reaped. |
Expand Down Expand Up @@ -415,6 +419,23 @@ 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 🙌

## Disk space for Docker workloads

The runner inherits the AMI's root volume size — 8 GiB on Amazon Linux 2023. Docker-based CI exhausts that almost immediately (a couple of large images plus build cache), and the job dies with `no space left on device` — one of the most common self-hosted-runner failures. Size the root volume for your workload:

```yml
- name: Start EC2 runner
uses: namecheap/ec2-github-runner@v3
with:
mode: start
# ... other inputs ...
volume-size: 100 # GiB
volume-type: gp3
volume-throughput: 250 # MiB/s (gp3 only, optional)
```

`volume-size` must be at least the AMI snapshot size (validated up front). The volume is always created with `DeleteOnTermination: true`, so it's removed with the ephemeral instance and never leaks. Sizing composes with `encrypt-ebs` — set both to get an encrypted, resized root volume in one shot.

## Reaping orphaned runners (`mode: cleanup`)

The `stop` step runs with `if: always()`, but that still doesn't cover every leak path — a cancelled workflow where the stop job never scheduled, a runner crash, a GitHub/AWS outage mid-run, or the workflow being killed after `start` but before `stop`. Every leaked instance bills until someone notices. Two independent, defense-in-depth layers close these paths.
Expand Down
22 changes: 22 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,28 @@ inputs:
this — opt in explicitly when you've verified the permissions.
required: false
default: 'false'
volume-size:
description: >-
Used only with the 'start' mode. Root EBS volume size in GiB. When
omitted, the AMI's default size is used (Amazon Linux 2023: 8 GiB,
which Docker-based CI exhausts quickly). Must be >= the AMI snapshot
size. Composes with encrypt-ebs.
required: false
volume-type:
description: >-
Used only with the 'start' mode. Root EBS volume type: one of gp3
(recommended), gp2, io1, io2. When omitted, the AMI default is used.
required: false
volume-iops:
description: >-
Used only with the 'start' mode. Provisioned IOPS for the root
volume. Only valid with volume-type io1, io2, or gp3.
required: false
volume-throughput:
description: >-
Used only with the 'start' mode. Root volume throughput in MiB/s.
Only valid with volume-type gp3.
required: false
http-tokens:
description: >-
Instance Metadata Service (IMDS) token mode. Accepted values:
Expand Down
133 changes: 113 additions & 20 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104747,11 +104747,42 @@ async function resolveImage(client) {
return { id: picked.ImageId, image: picked };
}

// Build BlockDeviceMappings that encrypt the AMI's root volume without
// changing its size, type, or iops. Returns null when no root mapping
// is present on the image (exotic AMIs) — caller should skip encryption
// and log a warning rather than ship a broken RunInstances call.
function buildEncryptedRootMapping(image) {
// True when the launch needs a custom root BlockDeviceMapping — i.e. the
// user opted into encryption or any root-volume override. When false the
// caller omits BlockDeviceMappings entirely, so the instance inherits the
// AMI's block device mapping byte-for-byte (zero-diff vs. the default).
function wantsRootDeviceMapping(input) {
return input.encryptEbs === 'true'
|| !!input.volumeSize
|| !!input.volumeType
|| !!input.volumeIops
|| !!input.volumeThroughput;
}

// Normalize the root-volume-related config inputs (strings) into a typed
// options object for buildRootDeviceMapping. Omitted inputs become
// undefined so the builder leaves the AMI default untouched.
function buildVolumeOpts(input) {
return {
encrypt: input.encryptEbs === 'true',
volumeSize: input.volumeSize ? Number(input.volumeSize) : undefined,
volumeType: input.volumeType || undefined,
volumeIops: input.volumeIops ? Number(input.volumeIops) : undefined,
volumeThroughput: input.volumeThroughput ? Number(input.volumeThroughput) : undefined,
};
}

// Build BlockDeviceMappings for the AMI's root volume, composing
// encryption (encrypt-ebs) and sizing (volume-size/type/iops/throughput)
// into a single mapping — one writer, not two competing ones. Clones the
// AMI's root Ebs config, drops SnapshotId (AWS uses the AMI's snapshot
// automatically), and applies only the requested overrides so omitted
// inputs keep AMI defaults. DeleteOnTermination is always forced true —
// ephemeral runners must never leak their root volume. Returns null when
// the AMI has no root EBS mapping (exotic AMIs); the caller logs a warning
// rather than shipping a broken RunInstances call. Throws when the
// requested size is smaller than the AMI snapshot (invalid, fail fast).
function buildRootDeviceMapping(image, opts = {}) {
const rootDev = image.RootDeviceName;
if (!rootDev || !Array.isArray(image.BlockDeviceMappings)) {
return null;
Expand All @@ -104760,15 +104791,35 @@ function buildEncryptedRootMapping(image) {
if (!rootMap || !rootMap.Ebs) {
return null;
}
// Clone the EBS config and set Encrypted: true. Drop SnapshotId — AWS
// will use the AMI's snapshot automatically and re-encrypt during
// launch under the account's default EBS key.
const ebsClone = { ...rootMap.Ebs };
delete ebsClone.SnapshotId;
return [{
DeviceName: rootDev,
Ebs: { ...ebsClone, Encrypted: true },
}];

const ebs = { ...rootMap.Ebs };
const snapshotSize = ebs.VolumeSize;
delete ebs.SnapshotId;
ebs.DeleteOnTermination = true;

if (opts.encrypt) {
ebs.Encrypted = true;
}
if (opts.volumeSize != null) {
if (snapshotSize != null && opts.volumeSize < snapshotSize) {
throw new Error(
`volume-size ${opts.volumeSize} GiB is smaller than the AMI snapshot size ${snapshotSize} GiB; ` +
'an EBS volume cannot be smaller than the snapshot it is created from.',
);
}
ebs.VolumeSize = opts.volumeSize;
}
if (opts.volumeType) {
ebs.VolumeType = opts.volumeType;
}
if (opts.volumeIops != null) {
ebs.Iops = opts.volumeIops;
}
if (opts.volumeThroughput != null) {
ebs.Throughput = opts.volumeThroughput;
}

return [{ DeviceName: rootDev, Ebs: ebs }];
}

// Build the cloud-init user-data bootstrap script.
Expand Down Expand Up @@ -104985,15 +105036,21 @@ async function startEc2Instance(label, githubRegistrationToken) {
params.InstanceInitiatedShutdownBehavior = 'terminate';
}

if (config.input.encryptEbs === 'true') {
const mappings = buildEncryptedRootMapping(resolved.image);
if (wantsRootDeviceMapping(config.input)) {
const mappings = buildRootDeviceMapping(resolved.image, buildVolumeOpts(config.input));
if (mappings) {
params.BlockDeviceMappings = mappings;
log.info('encrypt_ebs', { applied: true, root_device: mappings[0].DeviceName });
log.info('root_volume', {
applied: true,
root_device: mappings[0].DeviceName,
encrypted: mappings[0].Ebs.Encrypted === true,
volume_size: mappings[0].Ebs.VolumeSize,
volume_type: mappings[0].Ebs.VolumeType,
});
} else {
log.warn('encrypt_ebs', {
log.warn('root_volume', {
applied: false,
reason: 'ami has no root EBS block-device mapping — skipping encryption override',
reason: 'ami has no root EBS block-device mapping — skipping encryption/sizing override',
ami_id: resolved.id,
});
}
Expand Down Expand Up @@ -105212,7 +105269,9 @@ module.exports = {
handleStartFailure,
listManagedInstances,
// Exported for unit testing.
buildEncryptedRootMapping,
buildRootDeviceMapping,
wantsRootDeviceMapping,
buildVolumeOpts,
buildUserData,
buildTagSpecifications,
redactSecrets,
Expand Down Expand Up @@ -105393,6 +105452,10 @@ class Config {
runnerVersion: core.getInput('runner-version') || '2.335.1',
httpTokens: core.getInput('http-tokens') || 'required',
encryptEbs: core.getInput('encrypt-ebs') || 'false',
volumeSize: core.getInput('volume-size'),
volumeType: core.getInput('volume-type'),
volumeIops: core.getInput('volume-iops'),
volumeThroughput: core.getInput('volume-throughput'),
cleanupOnStartFailure: core.getInput('cleanup-on-start-failure') || 'true',
maxLifetimeMinutes: core.getInput('max-lifetime-minutes') || '360',
maxAgeMinutes: core.getInput('max-age-minutes') || '120',
Expand Down Expand Up @@ -105433,6 +105496,7 @@ class Config {
if (!this.input.ec2ImageId && !this.input.ec2ImageFilters) {
throw new Error(`Not all the required inputs for AMI search are provided for the 'start' mode`);
}
this.validateVolumeInputs();
} 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 All @@ -105446,6 +105510,35 @@ class Config {
}
}

// Validate the root-volume inputs against EBS rules that don't need the
// AMI (fail in seconds at config parse). The size-vs-snapshot check needs
// the DescribeImages data and lives in src/aws.js buildRootDeviceMapping.
validateVolumeInputs() {
const { volumeSize, volumeType, volumeIops, volumeThroughput } = this.input;
const ALLOWED_TYPES = ['gp3', 'gp2', 'io1', 'io2'];
const IOPS_TYPES = ['gp3', 'io1', 'io2'];
const isPositiveInt = (v) => /^[0-9]+$/.test(v) && Number(v) > 0;

if (volumeSize && !isPositiveInt(volumeSize)) {
throw new Error(`'volume-size' must be a positive integer (GiB)`);
}
if (volumeIops && !isPositiveInt(volumeIops)) {
throw new Error(`'volume-iops' must be a positive integer`);
}
if (volumeThroughput && !isPositiveInt(volumeThroughput)) {
throw new Error(`'volume-throughput' must be a positive integer (MiB/s)`);
}
if (volumeType && !ALLOWED_TYPES.includes(volumeType)) {
throw new Error(`'volume-type' must be one of: ${ALLOWED_TYPES.join(', ')}`);
}
if (volumeIops && !IOPS_TYPES.includes(volumeType)) {
throw new Error(`'volume-iops' is only valid with 'volume-type' one of: ${IOPS_TYPES.join(', ')}`);
}
if (volumeThroughput && volumeType !== 'gp3') {
throw new Error(`'volume-throughput' is only valid with 'volume-type' gp3`);
}
}

generateUniqueLabel() {
return Math.random().toString(36).substr(2, 5);
}
Expand Down
99 changes: 79 additions & 20 deletions src/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,42 @@ async function resolveImage(client) {
return { id: picked.ImageId, image: picked };
}

// Build BlockDeviceMappings that encrypt the AMI's root volume without
// changing its size, type, or iops. Returns null when no root mapping
// is present on the image (exotic AMIs) — caller should skip encryption
// and log a warning rather than ship a broken RunInstances call.
function buildEncryptedRootMapping(image) {
// True when the launch needs a custom root BlockDeviceMapping — i.e. the
// user opted into encryption or any root-volume override. When false the
// caller omits BlockDeviceMappings entirely, so the instance inherits the
// AMI's block device mapping byte-for-byte (zero-diff vs. the default).
function wantsRootDeviceMapping(input) {
return input.encryptEbs === 'true'
|| !!input.volumeSize
|| !!input.volumeType
|| !!input.volumeIops
|| !!input.volumeThroughput;
}

// Normalize the root-volume-related config inputs (strings) into a typed
// options object for buildRootDeviceMapping. Omitted inputs become
// undefined so the builder leaves the AMI default untouched.
function buildVolumeOpts(input) {
return {
encrypt: input.encryptEbs === 'true',
volumeSize: input.volumeSize ? Number(input.volumeSize) : undefined,
volumeType: input.volumeType || undefined,
volumeIops: input.volumeIops ? Number(input.volumeIops) : undefined,
volumeThroughput: input.volumeThroughput ? Number(input.volumeThroughput) : undefined,
};
}

// Build BlockDeviceMappings for the AMI's root volume, composing
// encryption (encrypt-ebs) and sizing (volume-size/type/iops/throughput)
// into a single mapping — one writer, not two competing ones. Clones the
// AMI's root Ebs config, drops SnapshotId (AWS uses the AMI's snapshot
// automatically), and applies only the requested overrides so omitted
// inputs keep AMI defaults. DeleteOnTermination is always forced true —
// ephemeral runners must never leak their root volume. Returns null when
// the AMI has no root EBS mapping (exotic AMIs); the caller logs a warning
// rather than shipping a broken RunInstances call. Throws when the
// requested size is smaller than the AMI snapshot (invalid, fail fast).
function buildRootDeviceMapping(image, opts = {}) {
const rootDev = image.RootDeviceName;
if (!rootDev || !Array.isArray(image.BlockDeviceMappings)) {
return null;
Expand All @@ -110,15 +141,35 @@ function buildEncryptedRootMapping(image) {
if (!rootMap || !rootMap.Ebs) {
return null;
}
// Clone the EBS config and set Encrypted: true. Drop SnapshotId — AWS
// will use the AMI's snapshot automatically and re-encrypt during
// launch under the account's default EBS key.
const ebsClone = { ...rootMap.Ebs };
delete ebsClone.SnapshotId;
return [{
DeviceName: rootDev,
Ebs: { ...ebsClone, Encrypted: true },
}];

const ebs = { ...rootMap.Ebs };
const snapshotSize = ebs.VolumeSize;
delete ebs.SnapshotId;
ebs.DeleteOnTermination = true;

if (opts.encrypt) {
ebs.Encrypted = true;
}
if (opts.volumeSize != null) {
if (snapshotSize != null && opts.volumeSize < snapshotSize) {
throw new Error(
`volume-size ${opts.volumeSize} GiB is smaller than the AMI snapshot size ${snapshotSize} GiB; ` +
'an EBS volume cannot be smaller than the snapshot it is created from.',
);
}
ebs.VolumeSize = opts.volumeSize;
}
if (opts.volumeType) {
ebs.VolumeType = opts.volumeType;
}
if (opts.volumeIops != null) {
ebs.Iops = opts.volumeIops;
}
if (opts.volumeThroughput != null) {
ebs.Throughput = opts.volumeThroughput;
}

return [{ DeviceName: rootDev, Ebs: ebs }];
}

// Build the cloud-init user-data bootstrap script.
Expand Down Expand Up @@ -335,15 +386,21 @@ async function startEc2Instance(label, githubRegistrationToken) {
params.InstanceInitiatedShutdownBehavior = 'terminate';
}

if (config.input.encryptEbs === 'true') {
const mappings = buildEncryptedRootMapping(resolved.image);
if (wantsRootDeviceMapping(config.input)) {
const mappings = buildRootDeviceMapping(resolved.image, buildVolumeOpts(config.input));
if (mappings) {
params.BlockDeviceMappings = mappings;
log.info('encrypt_ebs', { applied: true, root_device: mappings[0].DeviceName });
log.info('root_volume', {
applied: true,
root_device: mappings[0].DeviceName,
encrypted: mappings[0].Ebs.Encrypted === true,
volume_size: mappings[0].Ebs.VolumeSize,
volume_type: mappings[0].Ebs.VolumeType,
});
} else {
log.warn('encrypt_ebs', {
log.warn('root_volume', {
applied: false,
reason: 'ami has no root EBS block-device mapping — skipping encryption override',
reason: 'ami has no root EBS block-device mapping — skipping encryption/sizing override',
ami_id: resolved.id,
});
}
Expand Down Expand Up @@ -562,7 +619,9 @@ module.exports = {
handleStartFailure,
listManagedInstances,
// Exported for unit testing.
buildEncryptedRootMapping,
buildRootDeviceMapping,
wantsRootDeviceMapping,
buildVolumeOpts,
buildUserData,
buildTagSpecifications,
redactSecrets,
Expand Down
Loading