Skip to content
This repository was archived by the owner on Jan 16, 2025. It is now read-only.

Commit 68e2381

Browse files
authored
feat(runners): Add support for looking up runner AMI ID from an SSM parameter at instance launch time (#2520)
* Add support for looking up a pre-built runner AMI ID from an SSM parameter * Format terraform code * Format TS code * Add tests * Make error message more helpful * Fixes per comments * Re-format terraform code * Sync var description * Add scale up test for overridden AMI ID * Fix iam role policy name * Add example
1 parent 5764f5b commit 68e2381

File tree

10 files changed

+150
-10
lines changed

10 files changed

+150
-10
lines changed

Diff for: README.md

+3
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,8 @@ export interface GithubWorkflowEvent {
329329
This extendible format allows to add more fields to be added if needed.
330330
You can configure the queue by setting properties to `workflow_job_events_queue_config`
331331

332+
NOTE: By default, a runner AMI update requires a re-apply of this terraform config (the runner AMI ID is looked up by a terraform data source). To avoid this, you can use `ami_id_ssm_parameter_name` to have the scale-up lambda dynamically lookup the runner AMI ID from an SSM parameter at instance launch time. Said SSM parameter is managed outside of this module (e.g. by a runner AMI build workflow).
333+
332334
## Examples
333335

334336
Examples are located in the [examples](./examples) directory. The following examples are provided:
@@ -419,6 +421,7 @@ We welcome any improvement to the standard module to make the default as secure
419421
|------|-------------|------|---------|:--------:|
420422
| <a name="input_ami_filter"></a> [ami\_filter](#input\_ami\_filter) | List of maps used to create the AMI filter for the action runner AMI. By default amazon linux 2 is used. | `map(list(string))` | `null` | no |
421423
| <a name="input_ami_owners"></a> [ami\_owners](#input\_ami\_owners) | The list of owners used to select the AMI of action runner instances. | `list(string)` | <pre>[<br> "amazon"<br>]</pre> | no |
424+
| <a name="input_ami_id_ssm_parameter_name"></a> [ami\_id\_ssm\_parameter\_name](#input\_ami\_id\_ssm\_parameter\_name) | Optional SSM parameter that contains the runner AMI ID to launch instances from. Overrides `ami_filter`. The parameter value is managed outside of this module (e.g. in a runner AMI build workflow). This allows for AMI updates without having to re-apply this terraform config. | `string` | `null` | no |
422425
| <a name="input_aws_partition"></a> [aws\_partition](#input\_aws\_partition) | (optiona) partition in the arn namespace to use if not 'aws' | `string` | `"aws"` | no |
423426
| <a name="input_aws_region"></a> [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes |
424427
| <a name="input_block_device_mappings"></a> [block\_device\_mappings](#input\_block\_device\_mappings) | The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`. | <pre>list(object({<br> delete_on_termination = bool<br> device_name = string<br> encrypted = bool<br> iops = number<br> kms_key_id = string<br> snapshot_id = string<br> throughput = number<br> volume_size = number<br> volume_type = string<br> }))</pre> | <pre>[<br> {<br> "delete_on_termination": true,<br> "device_name": "/dev/xvda",<br> "encrypted": true,<br> "iops": null,<br> "kms_key_id": null,<br> "snapshot_id": null,<br> "throughput": null,<br> "volume_size": 30,<br> "volume_type": "gp3"<br> }<br>]</pre> | no |

Diff for: examples/prebuilt/main.tf

+4
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ module "runners" {
4545
ami_filter = { name = [var.ami_name_filter] }
4646
ami_owners = [data.aws_caller_identity.current.account_id]
4747

48+
# Look up runner AMI ID from an AWS SSM parameter (overrides ami_filter at instance launch time)
49+
# NOTE: the parameter must be managed outside of this module (e.g. in a runner AMI build workflow)
50+
# ami_id_ssm_parameter_name = "my-runner-ami-id"
51+
4852
# disable binary syncer since github agent is already installed in the AMI.
4953
enable_runner_binaries_syncer = false
5054

Diff for: main.tf

+4-3
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,10 @@ module "runners" {
178178
instance_max_spot_price = var.instance_max_spot_price
179179
block_device_mappings = var.block_device_mappings
180180

181-
runner_architecture = var.runner_architecture
182-
ami_filter = var.ami_filter
183-
ami_owners = var.ami_owners
181+
runner_architecture = var.runner_architecture
182+
ami_filter = var.ami_filter
183+
ami_owners = var.ami_owners
184+
ami_id_ssm_parameter_name = var.ami_id_ssm_parameter_name
184185

185186
sqs_build_queue = aws_sqs_queue.queued_builds
186187
github_app_parameters = local.github_app_parameters

Diff for: modules/runners/lambdas/runners/src/aws/runners.test.ts

+56-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import ScaleError from './../scale-runners/ScaleError';
44
import { RunnerInfo, RunnerInputParameters, createRunner, listEC2Runners, terminateRunner } from './runners';
55

66
const mockEC2 = { describeInstances: jest.fn(), createFleet: jest.fn(), terminateInstances: jest.fn() };
7-
const mockSSM = { putParameter: jest.fn() };
7+
const mockSSM = { putParameter: jest.fn(), getParameter: jest.fn() };
88
jest.mock('aws-sdk', () => ({
99
EC2: jest.fn().mockImplementation(() => mockEC2),
1010
SSM: jest.fn().mockImplementation(() => mockSSM),
@@ -170,6 +170,8 @@ describe('terminate runner', () => {
170170
describe('create runner', () => {
171171
const mockCreateFleet = { promise: jest.fn() };
172172
const mockPutParameter = { promise: jest.fn() };
173+
const mockGetParameter = { promise: jest.fn() };
174+
173175
const defaultRunnerConfig: RunnerConfig = {
174176
allocationStrategy: 'capacity-optimized',
175177
capacityType: 'spot',
@@ -191,6 +193,8 @@ describe('create runner', () => {
191193
Instances: [{ InstanceIds: ['i-1234'] }],
192194
});
193195
mockSSM.putParameter.mockImplementation(() => mockPutParameter);
196+
197+
mockSSM.getParameter.mockImplementation(() => mockGetParameter);
194198
});
195199

196200
it('calls create fleet of 1 instance with the correct config for repo', async () => {
@@ -259,6 +263,21 @@ describe('create runner', () => {
259263
await expect(createRunner(createRunnerConfig(defaultRunnerConfig))).rejects.toThrowError(Error);
260264
expect(mockSSM.putParameter).not.toBeCalled();
261265
});
266+
267+
it('uses ami id from ssm parameter when ami id ssm param is specified', async () => {
268+
const paramValue: AWS.SSM.GetParameterResult = {
269+
Parameter: {
270+
Value: 'ami-123',
271+
},
272+
};
273+
mockGetParameter.promise.mockReturnValue(paramValue);
274+
await createRunner(createRunnerConfig({ ...defaultRunnerConfig, amiIdSsmParameterName: 'my-ami-id-param' }));
275+
const expectedRequest = expectedCreateFleetRequest({ ...defaultExpectedFleetRequestValues, imageId: 'ami-123' });
276+
expect(mockEC2.createFleet).toBeCalledWith(expectedRequest);
277+
expect(mockSSM.getParameter).toBeCalledWith({
278+
Name: 'my-ami-id-param',
279+
});
280+
});
262281
});
263282

264283
describe('create runner with errors', () => {
@@ -279,6 +298,10 @@ describe('create runner with errors', () => {
279298
const mockPutParameter = { promise: jest.fn() };
280299

281300
mockSSM.putParameter.mockImplementation(() => mockPutParameter);
301+
302+
const mockGetParameter = { promise: jest.fn() };
303+
304+
mockSSM.getParameter.mockImplementation(() => mockGetParameter);
282305
});
283306

284307
it('test ScaleError with one error.', async () => {
@@ -326,6 +349,22 @@ describe('create runner with errors', () => {
326349
expect(mockEC2.createFleet).toBeCalledWith(expectedCreateFleetRequest(defaultExpectedFleetRequestValues));
327350
expect(mockSSM.putParameter).not.toBeCalled();
328351
});
352+
353+
it('test error in ami id lookup from ssm parameter', async () => {
354+
mockSSM.getParameter.mockImplementation(() => {
355+
return {
356+
promise: jest.fn().mockImplementation(() => {
357+
throw Error('Wow, such transient');
358+
}),
359+
};
360+
});
361+
362+
await expect(
363+
createRunner(createRunnerConfig({ ...defaultRunnerConfig, amiIdSsmParameterName: 'my-ami-id-param' })),
364+
).rejects.toBeInstanceOf(Error);
365+
expect(mockEC2.createFleet).not.toBeCalled();
366+
expect(mockSSM.putParameter).not.toBeCalled();
367+
});
329368
});
330369

331370
function createFleetMockWithErrors(errors: string[], instances?: string[]) {
@@ -354,6 +393,7 @@ interface RunnerConfig {
354393
capacityType: EC2.DefaultTargetCapacityType;
355394
allocationStrategy: EC2.AllocationStrategy;
356395
maxSpotPrice?: string;
396+
amiIdSsmParameterName?: string;
357397
}
358398

359399
function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters {
@@ -370,6 +410,7 @@ function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters {
370410
instanceAllocationStrategy: runnerConfig.allocationStrategy,
371411
},
372412
subnets: ['subnet-123', 'subnet-456'],
413+
amiIdSsmParameterName: runnerConfig.amiIdSsmParameterName,
373414
};
374415
}
375416

@@ -379,10 +420,11 @@ interface ExpectedFleetRequestValues {
379420
allocationStrategy: EC2.AllocationStrategy;
380421
maxSpotPrice?: string;
381422
totalTargetCapacity: number;
423+
imageId?: string;
382424
}
383425

384426
function expectedCreateFleetRequest(expectedValues: ExpectedFleetRequestValues): AWS.EC2.CreateFleetRequest {
385-
return {
427+
const request: AWS.EC2.CreateFleetRequest = {
386428
LaunchTemplateConfigs: [
387429
{
388430
LaunchTemplateSpecification: {
@@ -429,4 +471,16 @@ function expectedCreateFleetRequest(expectedValues: ExpectedFleetRequestValues):
429471
},
430472
Type: 'instant',
431473
};
474+
475+
if (expectedValues.imageId) {
476+
for (const config of request.LaunchTemplateConfigs) {
477+
if (config.Overrides) {
478+
for (const override of config.Overrides) {
479+
override.ImageId = expectedValues.imageId;
480+
}
481+
}
482+
}
483+
}
484+
485+
return request;
432486
}

Diff for: modules/runners/lambdas/runners/src/aws/runners.ts

+37-5
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface RunnerInputParameters {
4242
instanceAllocationStrategy: EC2.SpotAllocationStrategy;
4343
};
4444
numberOfRunners?: number;
45+
amiIdSsmParameterName?: string;
4546
}
4647

4748
export async function listEC2Runners(filters: ListRunnerFilters | undefined = undefined): Promise<RunnerList[]> {
@@ -107,17 +108,27 @@ export async function terminateRunner(instanceId: string): Promise<void> {
107108
logger.info(`Runner ${instanceId} has been terminated.`, LogFields.print());
108109
}
109110

110-
function generateFleeOverrides(
111+
function generateFleetOverrides(
111112
subnetIds: string[],
112113
instancesTypes: string[],
114+
amiId?: string,
113115
): EC2.FleetLaunchTemplateOverridesListRequest {
116+
type Override = {
117+
SubnetId: string;
118+
InstanceType: string;
119+
ImageId?: string;
120+
};
114121
const result: EC2.FleetLaunchTemplateOverridesListRequest = [];
115122
subnetIds.forEach((s) => {
116123
instancesTypes.forEach((i) => {
117-
result.push({
124+
const item: Override = {
118125
SubnetId: s,
119126
InstanceType: i,
120-
});
127+
};
128+
if (amiId) {
129+
item.ImageId = amiId;
130+
}
131+
result.push(item);
121132
});
122133
});
123134
return result;
@@ -127,6 +138,27 @@ export async function createRunner(runnerParameters: RunnerInputParameters): Pro
127138
logger.debug('Runner configuration: ' + JSON.stringify(runnerParameters), LogFields.print());
128139

129140
const ec2 = new EC2();
141+
const ssm = new SSM();
142+
143+
let amiIdOverride = undefined;
144+
145+
if (runnerParameters.amiIdSsmParameterName) {
146+
logger.debug(`Looking up runner AMI ID from SSM parameter: ${runnerParameters.amiIdSsmParameterName}`);
147+
try {
148+
const result: AWS.SSM.GetParameterResult = await ssm
149+
.getParameter({ Name: runnerParameters.amiIdSsmParameterName })
150+
.promise();
151+
amiIdOverride = result.Parameter?.Value;
152+
} catch (e) {
153+
logger.error(
154+
`Failed to lookup runner AMI ID from SSM parameter: ${runnerParameters.amiIdSsmParameterName}. ` +
155+
'Please ensure that the given parameter exists on this region and contains a valid runner AMI ID',
156+
e,
157+
);
158+
throw e;
159+
}
160+
}
161+
130162
const numberOfRunners = runnerParameters.numberOfRunners ? runnerParameters.numberOfRunners : 1;
131163

132164
let fleet: AWS.EC2.CreateFleetResult;
@@ -140,9 +172,10 @@ export async function createRunner(runnerParameters: RunnerInputParameters): Pro
140172
LaunchTemplateName: runnerParameters.launchTemplateName,
141173
Version: '$Default',
142174
},
143-
Overrides: generateFleeOverrides(
175+
Overrides: generateFleetOverrides(
144176
runnerParameters.subnets,
145177
runnerParameters.ec2instanceCriteria.instanceTypes,
178+
amiIdOverride,
146179
),
147180
},
148181
],
@@ -202,7 +235,6 @@ export async function createRunner(runnerParameters: RunnerInputParameters): Pro
202235

203236
logger.info('Created instance(s): ', instances.join(','), LogFields.print());
204237

205-
const ssm = new SSM();
206238
for (const instance of instances) {
207239
await ssm
208240
.putParameter({

Diff for: modules/runners/lambdas/runners/src/scale-runners/scale-up.test.ts

+6
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@ describe('scaleUp with GHES', () => {
243243
];
244244
expect(createRunner).toBeCalledWith(expectedRunnerParams);
245245
});
246+
247+
it('creates a runner with ami id override from ssm parameter', async () => {
248+
process.env.AMI_ID_SSM_PARAMETER_NAME = 'my-ami-id-param';
249+
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
250+
expect(createRunner).toBeCalledWith({ ...expectedRunnerParams, amiIdSsmParameterName: 'my-ami-id-param' });
251+
});
246252
});
247253

248254
describe('on repo level', () => {

Diff for: modules/runners/lambdas/runners/src/scale-runners/scale-up.ts

+3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ interface CreateEC2RunnerConfig {
3232
launchTemplateName: string;
3333
ec2instanceCriteria: RunnerInputParameters['ec2instanceCriteria'];
3434
numberOfRunners?: number;
35+
amiIdSsmParameterName?: string;
3536
}
3637

3738
function generateRunnerServiceConfig(githubRunnerConfig: CreateGitHubRunnerConfig, token: string) {
@@ -159,6 +160,7 @@ export async function scaleUp(eventSource: string, payload: ActionRequestMessage
159160
const instanceMaxSpotPrice = process.env.INSTANCE_MAX_SPOT_PRICE;
160161
const instanceAllocationStrategy = process.env.INSTANCE_ALLOCATION_STRATEGY || 'lowest-price'; // same as AWS default
161162
const enableJobQueuedCheck = yn(process.env.ENABLE_JOB_QUEUED_CHECK, { default: true });
163+
const amiIdSsmParameterName = process.env.AMI_ID_SSM_PARAMETER_NAME;
162164

163165
if (ephemeralEnabled && payload.eventType !== 'workflow_job') {
164166
logger.warn(
@@ -222,6 +224,7 @@ export async function scaleUp(eventSource: string, payload: ActionRequestMessage
222224
environment,
223225
launchTemplateName,
224226
subnets,
227+
amiIdSsmParameterName,
225228
},
226229
githubInstallationClient,
227230
);

Diff for: modules/runners/scale-up.tf

+23
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ resource "aws_lambda_function" "scale_up" {
3636
RUNNER_GROUP_NAME = var.runner_group_name
3737
RUNNERS_MAXIMUM_COUNT = var.runners_maximum_count
3838
SUBNET_IDS = join(",", var.subnet_ids)
39+
AMI_ID_SSM_PARAMETER_NAME = var.ami_id_ssm_parameter_name
3940
}
4041
}
4142

@@ -110,3 +111,25 @@ resource "aws_iam_role_policy_attachment" "scale_up_vpc_execution_role" {
110111
role = aws_iam_role.scale_up.name
111112
policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
112113
}
114+
115+
resource "aws_iam_role_policy" "ami_id_ssm_parameter_read" {
116+
count = var.ami_id_ssm_parameter_name != null ? 1 : 0
117+
name = "${var.prefix}-ami-id-ssm-parameter-read"
118+
role = aws_iam_role.scale_up.name
119+
policy = <<-JSON
120+
{
121+
"Version": "2012-10-17",
122+
"Statement": [
123+
{
124+
"Effect": "Allow",
125+
"Action": [
126+
"ssm:GetParameter"
127+
],
128+
"Resource": [
129+
"arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${trimprefix(var.ami_id_ssm_parameter_name, "/")}"
130+
]
131+
}
132+
]
133+
}
134+
JSON
135+
}

Diff for: modules/runners/variables.tf

+6
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ variable "ami_owners" {
155155
default = ["amazon"]
156156
}
157157

158+
variable "ami_id_ssm_parameter_name" {
159+
description = "Externally managed SSM parameter (of data type aws:ec2:image) that contains the AMI ID to launch runner instances from. Overrides ami_filter"
160+
type = string
161+
default = null
162+
}
163+
158164
variable "enabled_userdata" {
159165
description = "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI"
160166
type = bool

Diff for: variables.tf

+8
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,19 @@ variable "ami_filter" {
302302
type = map(list(string))
303303
default = null
304304
}
305+
305306
variable "ami_owners" {
306307
description = "The list of owners used to select the AMI of action runner instances."
307308
type = list(string)
308309
default = ["amazon"]
309310
}
311+
312+
variable "ami_id_ssm_parameter_name" {
313+
description = "Externally managed SSM parameter (of data type aws:ec2:image) that contains the AMI ID to launch runner instances from. Overrides ami_filter"
314+
type = string
315+
default = null
316+
}
317+
310318
variable "lambda_s3_bucket" {
311319
description = "S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly."
312320
default = null

0 commit comments

Comments
 (0)