Skip to content

Commit 7c6a9a7

Browse files
authored
feat: opt-in EBS encryption for runner root volume (Phase 6.b) (#27)
Rounds out Phase 6 (IMDSv2 landed in #24, EBS encryption deferred until a per-AMI root-device lookup could be done safely). ## Change New 'encrypt-ebs' input on action.yml, default 'false' (opt-in). When 'true', the action: 1. Fetches the AMI's DescribeImages result (already needed to resolve image IDs when 'ec2-image-filters' is set). 2. Finds the BlockDeviceMapping matching the AMI's RootDeviceName. 3. Clones that mapping, drops SnapshotId (AWS uses the AMI's snapshot automatically), sets 'Encrypted: true'. 4. Passes the cloned mapping as RunInstances.BlockDeviceMappings. Result: root volume launches with SSE-EBS, key 'alias/aws/ebs' in the launch account. VolumeSize / VolumeType / IOPS / DeleteOnTermination preserved from the AMI — only the encryption bit is new. ## Why opt-in The launch account (not necessarily the AMI owner account) must have either default EBS encryption enabled, or at minimum permission to use the default AWS-managed KMS key. If the AMI's snapshot is encrypted with a customer-managed key that doesn't have a cross- account grant, RunInstances fails. Defaulting to 'true' would regress every consumer whose IAM / KMS policy isn't set up for this. Default 'false' lets each consumer opt in after verifying their account can handle it. ## Why not account-level default encryption AWS supports 'aws ec2 enable-ebs-encryption-by-default' at the account level — and that's the preferred belt-and-suspenders. But not every consumer runs in an AWS account they control (e.g., Namecheap's CI runs in a shared org account). Action-side opt-in is the only portable control. ## Refactor alongside resolveImageId -> resolveImage: now returns both the ID and the full Image metadata. Callers that only need the ID use .id; the EBS-encryption code path uses .image.BlockDeviceMappings to build the encrypted clone. ## Tests tests/ebs.test.js — 6 new cases for buildEncryptedRootMapping: happy path with full EBS config + non-EBS sibling mapping, volume type / size / iops preservation, and five null-return paths for exotic AMI shapes (no RootDeviceName, no mappings, non-EBS root, orphan RootDeviceName). tests/config.test.js — 2 new cases for the encrypt-ebs input (default fallback + override). Total: 44 -> 52 tests. ## Consumer dogfood Separate PR on terraform-provider-namecheap rotates the pin and enables 'encrypt-ebs: true' on the CI job. If the dogfood fails with a KMS / IAM permissions error, we know the account needs policy work before enabling; action-side code is fine either way. Signed-off-by: yuriyryabikov <22548029+kurok@users.noreply.github.com>
1 parent 0fdd401 commit 7c6a9a7

6 files changed

Lines changed: 234 additions & 12 deletions

File tree

action.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ inputs:
7979
override, add the corresponding hash to the table in a PR.
8080
required: false
8181
default: '2.333.1'
82+
encrypt-ebs:
83+
description: >-
84+
When 'true', the root EBS volume is created with SSE-EBS
85+
encryption enabled (AWS-managed KMS key, 'alias/aws/ebs', in
86+
the launch account). Requires that the account either has
87+
default EBS encryption enabled or can use the default AWS-
88+
managed KMS key. The AMI's BlockDeviceMapping is cloned and
89+
patched with 'Encrypted: true'; volume size / type / IOPS
90+
are preserved from the AMI. Default 'false' to avoid
91+
regressing consumers whose IAM / KMS policy doesn't allow
92+
this — opt in explicitly when you've verified the permissions.
93+
required: false
94+
default: 'false'
8295
http-tokens:
8396
description: >-
8497
Instance Metadata Service (IMDS) token mode. Accepted values:

dist/index.js

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87884,9 +87884,17 @@ async function waitForInstanceRunning(ec2InstanceId) {
8788487884
}
8788587885
}
8788687886

87887-
async function resolveImageId(client) {
87887+
async function resolveImage(client) {
87888+
// Resolves both the image ID and the image's metadata (root-device +
87889+
// block-device mappings). Callers that only need the ID use the .id
87890+
// shortcut; the .image field is used by encrypt-ebs to clone the
87891+
// AMI's BlockDeviceMappings and layer SSE-EBS onto them.
8788887892
if (config.input.ec2ImageId) {
87889-
return config.input.ec2ImageId;
87893+
const direct = await client.send(new DescribeImagesCommand({ ImageIds: [config.input.ec2ImageId] }));
87894+
if (!direct.Images || direct.Images.length === 0) {
87895+
throw new Error(`Unable to describe AMI ${config.input.ec2ImageId}`);
87896+
}
87897+
return { id: config.input.ec2ImageId, image: direct.Images[0] };
8789087898
}
8789187899

8789287900
const amiParams = {
@@ -87906,10 +87914,34 @@ async function resolveImageId(client) {
8790687914
throw new Error('Unable to find AMI using passed filter');
8790787915
}
8790887916
sortByCreationDate(result);
87909-
const picked = result.Images[0].ImageId;
87910-
log.info('describe_images', { match_count: result.Images.length, selected_ami: picked });
87917+
const picked = result.Images[0];
87918+
log.info('describe_images', { match_count: result.Images.length, selected_ami: picked.ImageId });
8791187919
log.debug('describe_images_all', { images: result.Images.map(i => ({ id: i.ImageId, name: i.Name, created: i.CreationDate })) });
87912-
return picked;
87920+
return { id: picked.ImageId, image: picked };
87921+
}
87922+
87923+
// Build BlockDeviceMappings that encrypt the AMI's root volume without
87924+
// changing its size, type, or iops. Returns null when no root mapping
87925+
// is present on the image (exotic AMIs) — caller should skip encryption
87926+
// and log a warning rather than ship a broken RunInstances call.
87927+
function buildEncryptedRootMapping(image) {
87928+
const rootDev = image.RootDeviceName;
87929+
if (!rootDev || !Array.isArray(image.BlockDeviceMappings)) {
87930+
return null;
87931+
}
87932+
const rootMap = image.BlockDeviceMappings.find((b) => b.DeviceName === rootDev);
87933+
if (!rootMap || !rootMap.Ebs) {
87934+
return null;
87935+
}
87936+
// Clone the EBS config and set Encrypted: true. Drop SnapshotId — AWS
87937+
// will use the AMI's snapshot automatically and re-encrypt during
87938+
// launch under the account's default EBS key.
87939+
const ebsClone = { ...rootMap.Ebs };
87940+
delete ebsClone.SnapshotId;
87941+
return [{
87942+
DeviceName: rootDev,
87943+
Ebs: { ...ebsClone, Encrypted: true },
87944+
}];
8791387945
}
8791487946

8791587947
async function startEc2Instance(label, githubRegistrationToken) {
@@ -87999,7 +88031,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
8799988031
'',
8800088032
];
8800188033

88002-
config.input.ec2ImageId = await resolveImageId(client);
88034+
const resolved = await resolveImage(client);
88035+
config.input.ec2ImageId = resolved.id;
8800388036

8800488037
const params = {
8800588038
ImageId: config.input.ec2ImageId,
@@ -88022,6 +88055,20 @@ async function startEc2Instance(label, githubRegistrationToken) {
8802288055
},
8802388056
};
8802488057

88058+
if (config.input.encryptEbs === 'true') {
88059+
const mappings = buildEncryptedRootMapping(resolved.image);
88060+
if (mappings) {
88061+
params.BlockDeviceMappings = mappings;
88062+
log.info('encrypt_ebs', { applied: true, root_device: mappings[0].DeviceName });
88063+
} else {
88064+
log.warn('encrypt_ebs', {
88065+
applied: false,
88066+
reason: 'ami has no root EBS block-device mapping — skipping encryption override',
88067+
ami_id: resolved.id,
88068+
});
88069+
}
88070+
}
88071+
8802588072
let ec2InstanceId;
8802688073
const runStart = Date.now();
8802788074
log.info('run_instance', {
@@ -88085,6 +88132,8 @@ module.exports = {
8808588132
startEc2Instance,
8808688133
terminateEc2Instance,
8808788134
waitForInstanceRunning,
88135+
// Exported for unit testing.
88136+
buildEncryptedRootMapping,
8808888137
};
8808988138

8809088139

@@ -88113,6 +88162,7 @@ class Config {
8811388162
iamRoleName: core.getInput('iam-role-name'),
8811488163
runnerVersion: core.getInput('runner-version') || '2.333.1',
8811588164
httpTokens: core.getInput('http-tokens') || 'required',
88165+
encryptEbs: core.getInput('encrypt-ebs') || 'false',
8811688166
debug: core.getInput('debug') || 'false',
8811788167
};
8811888168

src/aws.js

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,17 @@ async function waitForInstanceRunning(ec2InstanceId) {
3838
}
3939
}
4040

41-
async function resolveImageId(client) {
41+
async function resolveImage(client) {
42+
// Resolves both the image ID and the image's metadata (root-device +
43+
// block-device mappings). Callers that only need the ID use the .id
44+
// shortcut; the .image field is used by encrypt-ebs to clone the
45+
// AMI's BlockDeviceMappings and layer SSE-EBS onto them.
4246
if (config.input.ec2ImageId) {
43-
return config.input.ec2ImageId;
47+
const direct = await client.send(new DescribeImagesCommand({ ImageIds: [config.input.ec2ImageId] }));
48+
if (!direct.Images || direct.Images.length === 0) {
49+
throw new Error(`Unable to describe AMI ${config.input.ec2ImageId}`);
50+
}
51+
return { id: config.input.ec2ImageId, image: direct.Images[0] };
4452
}
4553

4654
const amiParams = {
@@ -60,10 +68,34 @@ async function resolveImageId(client) {
6068
throw new Error('Unable to find AMI using passed filter');
6169
}
6270
sortByCreationDate(result);
63-
const picked = result.Images[0].ImageId;
64-
log.info('describe_images', { match_count: result.Images.length, selected_ami: picked });
71+
const picked = result.Images[0];
72+
log.info('describe_images', { match_count: result.Images.length, selected_ami: picked.ImageId });
6573
log.debug('describe_images_all', { images: result.Images.map(i => ({ id: i.ImageId, name: i.Name, created: i.CreationDate })) });
66-
return picked;
74+
return { id: picked.ImageId, image: picked };
75+
}
76+
77+
// Build BlockDeviceMappings that encrypt the AMI's root volume without
78+
// changing its size, type, or iops. Returns null when no root mapping
79+
// is present on the image (exotic AMIs) — caller should skip encryption
80+
// and log a warning rather than ship a broken RunInstances call.
81+
function buildEncryptedRootMapping(image) {
82+
const rootDev = image.RootDeviceName;
83+
if (!rootDev || !Array.isArray(image.BlockDeviceMappings)) {
84+
return null;
85+
}
86+
const rootMap = image.BlockDeviceMappings.find((b) => b.DeviceName === rootDev);
87+
if (!rootMap || !rootMap.Ebs) {
88+
return null;
89+
}
90+
// Clone the EBS config and set Encrypted: true. Drop SnapshotId — AWS
91+
// will use the AMI's snapshot automatically and re-encrypt during
92+
// launch under the account's default EBS key.
93+
const ebsClone = { ...rootMap.Ebs };
94+
delete ebsClone.SnapshotId;
95+
return [{
96+
DeviceName: rootDev,
97+
Ebs: { ...ebsClone, Encrypted: true },
98+
}];
6799
}
68100

69101
async function startEc2Instance(label, githubRegistrationToken) {
@@ -153,7 +185,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
153185
'',
154186
];
155187

156-
config.input.ec2ImageId = await resolveImageId(client);
188+
const resolved = await resolveImage(client);
189+
config.input.ec2ImageId = resolved.id;
157190

158191
const params = {
159192
ImageId: config.input.ec2ImageId,
@@ -176,6 +209,20 @@ async function startEc2Instance(label, githubRegistrationToken) {
176209
},
177210
};
178211

212+
if (config.input.encryptEbs === 'true') {
213+
const mappings = buildEncryptedRootMapping(resolved.image);
214+
if (mappings) {
215+
params.BlockDeviceMappings = mappings;
216+
log.info('encrypt_ebs', { applied: true, root_device: mappings[0].DeviceName });
217+
} else {
218+
log.warn('encrypt_ebs', {
219+
applied: false,
220+
reason: 'ami has no root EBS block-device mapping — skipping encryption override',
221+
ami_id: resolved.id,
222+
});
223+
}
224+
}
225+
179226
let ec2InstanceId;
180227
const runStart = Date.now();
181228
log.info('run_instance', {
@@ -239,4 +286,6 @@ module.exports = {
239286
startEc2Instance,
240287
terminateEc2Instance,
241288
waitForInstanceRunning,
289+
// Exported for unit testing.
290+
buildEncryptedRootMapping,
242291
};

src/config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class Config {
1818
iamRoleName: core.getInput('iam-role-name'),
1919
runnerVersion: core.getInput('runner-version') || '2.333.1',
2020
httpTokens: core.getInput('http-tokens') || 'required',
21+
encryptEbs: core.getInput('encrypt-ebs') || 'false',
2122
debug: core.getInput('debug') || 'false',
2223
};
2324

tests/config.test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,18 @@ describe('Config — runner-version input', () => {
143143
});
144144
});
145145

146+
describe('Config — encrypt-ebs input', () => {
147+
test('defaults to "false" when unset', () => {
148+
const config = loadConfig(startModeInputs);
149+
expect(config.input.encryptEbs).toBe('false');
150+
});
151+
152+
test('honors "true"', () => {
153+
const config = loadConfig({ ...startModeInputs, 'encrypt-ebs': 'true' });
154+
expect(config.input.encryptEbs).toBe('true');
155+
});
156+
});
157+
146158
describe('Config — http-tokens input', () => {
147159
test('defaults to "required" when unset', () => {
148160
const config = loadConfig(startModeInputs);

tests/ebs.test.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Tests for buildEncryptedRootMapping. The function is a pure transform
2+
// of a DescribeImages response — no AWS/GitHub stubbing required.
3+
//
4+
// aws.js requires ./config at module load; mock it so the require chain
5+
// resolves without a valid Config singleton (tests don't touch config-
6+
// dependent code paths in this file).
7+
8+
beforeAll(() => {
9+
jest.doMock('../src/config', () => ({
10+
input: { mode: 'start', debug: 'false' },
11+
githubContext: { owner: 'o', repo: 'r' },
12+
tagSpecifications: null,
13+
}));
14+
jest.doMock('@actions/core', () => ({
15+
info: jest.fn(), warning: jest.fn(), error: jest.fn(), setFailed: jest.fn(), getInput: jest.fn(),
16+
startGroup: jest.fn(), endGroup: jest.fn(),
17+
}));
18+
});
19+
20+
const { buildEncryptedRootMapping } = require('../src/aws');
21+
22+
describe('buildEncryptedRootMapping', () => {
23+
test('clones the AMI root mapping and flips Encrypted to true', () => {
24+
const image = {
25+
RootDeviceName: '/dev/xvda',
26+
BlockDeviceMappings: [
27+
{
28+
DeviceName: '/dev/xvda',
29+
Ebs: {
30+
SnapshotId: 'snap-abc',
31+
VolumeSize: 30,
32+
VolumeType: 'gp3',
33+
Iops: 3000,
34+
DeleteOnTermination: true,
35+
},
36+
},
37+
{ DeviceName: '/dev/sdb', VirtualName: 'ephemeral0' }, // non-EBS, should be ignored
38+
],
39+
};
40+
41+
const result = buildEncryptedRootMapping(image);
42+
43+
expect(result).toEqual([{
44+
DeviceName: '/dev/xvda',
45+
Ebs: {
46+
VolumeSize: 30,
47+
VolumeType: 'gp3',
48+
Iops: 3000,
49+
DeleteOnTermination: true,
50+
Encrypted: true,
51+
},
52+
}]);
53+
// SnapshotId must be dropped (AWS uses the AMI's snapshot automatically).
54+
expect(result[0].Ebs.SnapshotId).toBeUndefined();
55+
});
56+
57+
test('preserves volume type + size + IOPS untouched', () => {
58+
const image = {
59+
RootDeviceName: '/dev/sda1',
60+
BlockDeviceMappings: [{
61+
DeviceName: '/dev/sda1',
62+
Ebs: { VolumeSize: 100, VolumeType: 'io2', Iops: 10000 },
63+
}],
64+
};
65+
66+
const result = buildEncryptedRootMapping(image);
67+
68+
expect(result[0].Ebs.VolumeSize).toBe(100);
69+
expect(result[0].Ebs.VolumeType).toBe('io2');
70+
expect(result[0].Ebs.Iops).toBe(10000);
71+
expect(result[0].Ebs.Encrypted).toBe(true);
72+
});
73+
74+
test('returns null when the AMI has no root device name', () => {
75+
expect(buildEncryptedRootMapping({ BlockDeviceMappings: [] })).toBeNull();
76+
});
77+
78+
test('returns null when the AMI has no BlockDeviceMappings', () => {
79+
expect(buildEncryptedRootMapping({ RootDeviceName: '/dev/xvda' })).toBeNull();
80+
});
81+
82+
test('returns null when the root mapping has no Ebs sub-object', () => {
83+
const image = {
84+
RootDeviceName: '/dev/xvda',
85+
BlockDeviceMappings: [{ DeviceName: '/dev/xvda', VirtualName: 'ephemeral0' }],
86+
};
87+
expect(buildEncryptedRootMapping(image)).toBeNull();
88+
});
89+
90+
test('returns null when RootDeviceName points to a mapping that doesn\'t exist', () => {
91+
const image = {
92+
RootDeviceName: '/dev/xvda',
93+
BlockDeviceMappings: [{ DeviceName: '/dev/sdb', Ebs: { VolumeSize: 10 } }],
94+
};
95+
expect(buildEncryptedRootMapping(image)).toBeNull();
96+
});
97+
});

0 commit comments

Comments
 (0)