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
13 changes: 13 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ inputs:
override, add the corresponding hash to the table in a PR.
required: false
default: '2.333.1'
encrypt-ebs:
description: >-
When 'true', the root EBS volume is created with SSE-EBS
encryption enabled (AWS-managed KMS key, 'alias/aws/ebs', in
the launch account). Requires that the account either has
default EBS encryption enabled or can use the default AWS-
managed KMS key. The AMI's BlockDeviceMapping is cloned and
patched with 'Encrypted: true'; volume size / type / IOPS
are preserved from the AMI. Default 'false' to avoid
regressing consumers whose IAM / KMS policy doesn't allow
this — opt in explicitly when you've verified the permissions.
required: false
default: 'false'
http-tokens:
description: >-
Instance Metadata Service (IMDS) token mode. Accepted values:
Expand Down
62 changes: 56 additions & 6 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -87884,9 +87884,17 @@ async function waitForInstanceRunning(ec2InstanceId) {
}
}

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

const amiParams = {
Expand All @@ -87906,10 +87914,34 @@ async function resolveImageId(client) {
throw new Error('Unable to find AMI using passed filter');
}
sortByCreationDate(result);
const picked = result.Images[0].ImageId;
log.info('describe_images', { match_count: result.Images.length, selected_ami: picked });
const picked = result.Images[0];
log.info('describe_images', { match_count: result.Images.length, selected_ami: picked.ImageId });
log.debug('describe_images_all', { images: result.Images.map(i => ({ id: i.ImageId, name: i.Name, created: i.CreationDate })) });
return picked;
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) {
const rootDev = image.RootDeviceName;
if (!rootDev || !Array.isArray(image.BlockDeviceMappings)) {
return null;
}
const rootMap = image.BlockDeviceMappings.find((b) => b.DeviceName === rootDev);
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 },
}];
}

async function startEc2Instance(label, githubRegistrationToken) {
Expand Down Expand Up @@ -87999,7 +88031,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
'',
];

config.input.ec2ImageId = await resolveImageId(client);
const resolved = await resolveImage(client);
config.input.ec2ImageId = resolved.id;

const params = {
ImageId: config.input.ec2ImageId,
Expand All @@ -88022,6 +88055,20 @@ async function startEc2Instance(label, githubRegistrationToken) {
},
};

if (config.input.encryptEbs === 'true') {
const mappings = buildEncryptedRootMapping(resolved.image);
if (mappings) {
params.BlockDeviceMappings = mappings;
log.info('encrypt_ebs', { applied: true, root_device: mappings[0].DeviceName });
} else {
log.warn('encrypt_ebs', {
applied: false,
reason: 'ami has no root EBS block-device mapping — skipping encryption override',
ami_id: resolved.id,
});
}
}

let ec2InstanceId;
const runStart = Date.now();
log.info('run_instance', {
Expand Down Expand Up @@ -88085,6 +88132,8 @@ module.exports = {
startEc2Instance,
terminateEc2Instance,
waitForInstanceRunning,
// Exported for unit testing.
buildEncryptedRootMapping,
};


Expand Down Expand Up @@ -88113,6 +88162,7 @@ class Config {
iamRoleName: core.getInput('iam-role-name'),
runnerVersion: core.getInput('runner-version') || '2.333.1',
httpTokens: core.getInput('http-tokens') || 'required',
encryptEbs: core.getInput('encrypt-ebs') || 'false',
debug: core.getInput('debug') || 'false',
};

Expand Down
61 changes: 55 additions & 6 deletions src/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,17 @@ async function waitForInstanceRunning(ec2InstanceId) {
}
}

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

const amiParams = {
Expand All @@ -60,10 +68,34 @@ async function resolveImageId(client) {
throw new Error('Unable to find AMI using passed filter');
}
sortByCreationDate(result);
const picked = result.Images[0].ImageId;
log.info('describe_images', { match_count: result.Images.length, selected_ami: picked });
const picked = result.Images[0];
log.info('describe_images', { match_count: result.Images.length, selected_ami: picked.ImageId });
log.debug('describe_images_all', { images: result.Images.map(i => ({ id: i.ImageId, name: i.Name, created: i.CreationDate })) });
return picked;
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) {
const rootDev = image.RootDeviceName;
if (!rootDev || !Array.isArray(image.BlockDeviceMappings)) {
return null;
}
const rootMap = image.BlockDeviceMappings.find((b) => b.DeviceName === rootDev);
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 },
}];
}

async function startEc2Instance(label, githubRegistrationToken) {
Expand Down Expand Up @@ -153,7 +185,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
'',
];

config.input.ec2ImageId = await resolveImageId(client);
const resolved = await resolveImage(client);
config.input.ec2ImageId = resolved.id;

const params = {
ImageId: config.input.ec2ImageId,
Expand All @@ -176,6 +209,20 @@ async function startEc2Instance(label, githubRegistrationToken) {
},
};

if (config.input.encryptEbs === 'true') {
const mappings = buildEncryptedRootMapping(resolved.image);
if (mappings) {
params.BlockDeviceMappings = mappings;
log.info('encrypt_ebs', { applied: true, root_device: mappings[0].DeviceName });
} else {
log.warn('encrypt_ebs', {
applied: false,
reason: 'ami has no root EBS block-device mapping — skipping encryption override',
ami_id: resolved.id,
});
}
}

let ec2InstanceId;
const runStart = Date.now();
log.info('run_instance', {
Expand Down Expand Up @@ -239,4 +286,6 @@ module.exports = {
startEc2Instance,
terminateEc2Instance,
waitForInstanceRunning,
// Exported for unit testing.
buildEncryptedRootMapping,
};
1 change: 1 addition & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Config {
iamRoleName: core.getInput('iam-role-name'),
runnerVersion: core.getInput('runner-version') || '2.333.1',
httpTokens: core.getInput('http-tokens') || 'required',
encryptEbs: core.getInput('encrypt-ebs') || 'false',
debug: core.getInput('debug') || 'false',
};

Expand Down
12 changes: 12 additions & 0 deletions tests/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ describe('Config — runner-version input', () => {
});
});

describe('Config — encrypt-ebs input', () => {
test('defaults to "false" when unset', () => {
const config = loadConfig(startModeInputs);
expect(config.input.encryptEbs).toBe('false');
});

test('honors "true"', () => {
const config = loadConfig({ ...startModeInputs, 'encrypt-ebs': 'true' });
expect(config.input.encryptEbs).toBe('true');
});
});

describe('Config — http-tokens input', () => {
test('defaults to "required" when unset', () => {
const config = loadConfig(startModeInputs);
Expand Down
97 changes: 97 additions & 0 deletions tests/ebs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Tests for buildEncryptedRootMapping. The function is a pure transform
// of a DescribeImages response — no AWS/GitHub stubbing required.
//
// aws.js requires ./config at module load; mock it so the require chain
// resolves without a valid Config singleton (tests don't touch config-
// dependent code paths in this file).

beforeAll(() => {
jest.doMock('../src/config', () => ({
input: { mode: 'start', debug: 'false' },
githubContext: { owner: 'o', repo: 'r' },
tagSpecifications: null,
}));
jest.doMock('@actions/core', () => ({
info: jest.fn(), warning: jest.fn(), error: jest.fn(), setFailed: jest.fn(), getInput: jest.fn(),
startGroup: jest.fn(), endGroup: jest.fn(),
}));
});

const { buildEncryptedRootMapping } = require('../src/aws');

describe('buildEncryptedRootMapping', () => {
test('clones the AMI root mapping and flips Encrypted to true', () => {
const image = {
RootDeviceName: '/dev/xvda',
BlockDeviceMappings: [
{
DeviceName: '/dev/xvda',
Ebs: {
SnapshotId: 'snap-abc',
VolumeSize: 30,
VolumeType: 'gp3',
Iops: 3000,
DeleteOnTermination: true,
},
},
{ DeviceName: '/dev/sdb', VirtualName: 'ephemeral0' }, // non-EBS, should be ignored
],
};

const result = buildEncryptedRootMapping(image);

expect(result).toEqual([{
DeviceName: '/dev/xvda',
Ebs: {
VolumeSize: 30,
VolumeType: 'gp3',
Iops: 3000,
DeleteOnTermination: true,
Encrypted: true,
},
}]);
// SnapshotId must be dropped (AWS uses the AMI's snapshot automatically).
expect(result[0].Ebs.SnapshotId).toBeUndefined();
});

test('preserves volume type + size + IOPS untouched', () => {
const image = {
RootDeviceName: '/dev/sda1',
BlockDeviceMappings: [{
DeviceName: '/dev/sda1',
Ebs: { VolumeSize: 100, VolumeType: 'io2', Iops: 10000 },
}],
};

const result = buildEncryptedRootMapping(image);

expect(result[0].Ebs.VolumeSize).toBe(100);
expect(result[0].Ebs.VolumeType).toBe('io2');
expect(result[0].Ebs.Iops).toBe(10000);
expect(result[0].Ebs.Encrypted).toBe(true);
});

test('returns null when the AMI has no root device name', () => {
expect(buildEncryptedRootMapping({ BlockDeviceMappings: [] })).toBeNull();
});

test('returns null when the AMI has no BlockDeviceMappings', () => {
expect(buildEncryptedRootMapping({ RootDeviceName: '/dev/xvda' })).toBeNull();
});

test('returns null when the root mapping has no Ebs sub-object', () => {
const image = {
RootDeviceName: '/dev/xvda',
BlockDeviceMappings: [{ DeviceName: '/dev/xvda', VirtualName: 'ephemeral0' }],
};
expect(buildEncryptedRootMapping(image)).toBeNull();
});

test('returns null when RootDeviceName points to a mapping that doesn\'t exist', () => {
const image = {
RootDeviceName: '/dev/xvda',
BlockDeviceMappings: [{ DeviceName: '/dev/sdb', Ebs: { VolumeSize: 10 } }],
};
expect(buildEncryptedRootMapping(image)).toBeNull();
});
});
Loading