Skip to content

Commit 138257d

Browse files
committed
refactor(ami-housekeeper): delegate SSM batch fetching to shared getParameters utility
1 parent 6e95a5b commit 138257d

File tree

2 files changed

+54
-155
lines changed

2 files changed

+54
-155
lines changed

lambdas/functions/ami-housekeeper/src/ami.test.ts

Lines changed: 33 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ import {
1010
import {
1111
DescribeParametersCommand,
1212
DescribeParametersCommandOutput,
13-
GetParametersCommand,
1413
SSMClient,
1514
} from '@aws-sdk/client-ssm';
15+
import { getParameters } from '@aws-github-runner/aws-ssm-util';
1616
import { mockClient } from 'aws-sdk-client-mock';
1717
import 'aws-sdk-client-mock-jest/vitest';
1818

1919
import { AmiCleanupOptions, amiCleanup, defaultAmiCleanupOptions } from './ami';
2020
import { describe, it, expect, beforeEach, vi } from 'vitest';
2121
import { fail } from 'assert';
2222

23+
vi.mock('@aws-github-runner/aws-ssm-util');
24+
2325
process.env.AWS_REGION = 'eu-east-1';
2426
const deleteAmisOlderThenDays = 30;
2527
const date31DaysAgo = new Date(new Date().setDate(new Date().getDate() - (deleteAmisOlderThenDays + 1)));
@@ -83,23 +85,12 @@ describe("delete AMI's", () => {
8385
mockSSMClient.reset();
8486

8587
mockSSMClient.on(DescribeParametersCommand).resolves(ssmParameters);
86-
mockSSMClient.on(GetParametersCommand).resolves({
87-
Parameters: [
88-
{
89-
Name: 'ami-id/ami-ssm0001',
90-
Type: 'String',
91-
Value: 'ami-ssm0001',
92-
Version: 1,
93-
},
94-
{
95-
Name: 'ami-id/ami-ssm0002',
96-
Type: 'String',
97-
Value: 'ami-ssm0002',
98-
Version: 1,
99-
},
100-
],
101-
InvalidParameters: [],
102-
});
88+
vi.mocked(getParameters).mockResolvedValue(
89+
new Map([
90+
['ami-id/ami-ssm0001', 'ami-ssm0001'],
91+
['ami-id/ami-ssm0002', 'ami-ssm0002'],
92+
]),
93+
);
10394

10495
mockEC2Client.on(DescribeLaunchTemplatesCommand).resolves({
10596
LaunchTemplates: [
@@ -144,11 +135,7 @@ describe("delete AMI's", () => {
144135
expect(mockEC2Client).toHaveReceivedCommand(DescribeLaunchTemplatesCommand);
145136
expect(mockEC2Client).toHaveReceivedCommand(DescribeLaunchTemplateVersionsCommand);
146137
expect(mockSSMClient).toHaveReceivedCommand(DescribeParametersCommand);
147-
expect(mockSSMClient).toHaveReceivedCommandTimes(GetParametersCommand, 1);
148-
expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersCommand, {
149-
Names: ['ami-id/ami-ssm0001', 'ami-id/ami-ssm0002'],
150-
WithDecryption: true,
151-
});
138+
expect(getParameters).toHaveBeenCalledWith(['ami-id/ami-ssm0001', 'ami-id/ami-ssm0002']);
152139
});
153140

154141
it('should NOT delete instances in use.', async () => {
@@ -484,17 +471,9 @@ describe("delete AMI's", () => {
484471
],
485472
});
486473

487-
mockSSMClient.on(GetParametersCommand).resolves({
488-
Parameters: [
489-
{
490-
Name: '/github-runner/config/ami_id',
491-
Type: 'String',
492-
Value: 'ami-underscore0001',
493-
Version: 1,
494-
},
495-
],
496-
InvalidParameters: [],
497-
});
474+
vi.mocked(getParameters).mockResolvedValue(
475+
new Map([['/github-runner/config/ami_id', 'ami-underscore0001']]),
476+
);
498477

499478
await amiCleanup({
500479
minimumDaysOld: 0,
@@ -503,10 +482,7 @@ describe("delete AMI's", () => {
503482

504483
// AMI should not be deleted because it's referenced in SSM
505484
expect(mockEC2Client).not.toHaveReceivedCommand(DeregisterImageCommand);
506-
expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersCommand, {
507-
Names: ['/github-runner/config/ami_id'],
508-
WithDecryption: true,
509-
});
485+
expect(getParameters).toHaveBeenCalledWith(['/github-runner/config/ami_id']);
510486
expect(mockSSMClient).not.toHaveReceivedCommand(DescribeParametersCommand);
511487
});
512488

@@ -521,17 +497,9 @@ describe("delete AMI's", () => {
521497
],
522498
});
523499

524-
mockSSMClient.on(GetParametersCommand).resolves({
525-
Parameters: [
526-
{
527-
Name: '/github-runner/config/ami-id',
528-
Type: 'String',
529-
Value: 'ami-hyphen0001',
530-
Version: 1,
531-
},
532-
],
533-
InvalidParameters: [],
534-
});
500+
vi.mocked(getParameters).mockResolvedValue(
501+
new Map([['/github-runner/config/ami-id', 'ami-hyphen0001']]),
502+
);
535503

536504
await amiCleanup({
537505
minimumDaysOld: 0,
@@ -540,10 +508,7 @@ describe("delete AMI's", () => {
540508

541509
// AMI should not be deleted because it's referenced in SSM
542510
expect(mockEC2Client).not.toHaveReceivedCommand(DeregisterImageCommand);
543-
expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersCommand, {
544-
Names: ['/github-runner/config/ami-id'],
545-
WithDecryption: true,
546-
});
511+
expect(getParameters).toHaveBeenCalledWith(['/github-runner/config/ami-id']);
547512
expect(mockSSMClient).not.toHaveReceivedCommand(DescribeParametersCommand);
548513
});
549514

@@ -568,17 +533,9 @@ describe("delete AMI's", () => {
568533
],
569534
});
570535

571-
mockSSMClient.on(GetParametersCommand).resolves({
572-
Parameters: [
573-
{
574-
Name: '/some/path/ami-id',
575-
Type: 'String',
576-
Value: 'ami-wildcard0001',
577-
Version: 1,
578-
},
579-
],
580-
InvalidParameters: [],
581-
});
536+
vi.mocked(getParameters).mockResolvedValue(
537+
new Map([['/some/path/ami-id', 'ami-wildcard0001']]),
538+
);
582539

583540
await amiCleanup({
584541
minimumDaysOld: 0,
@@ -590,10 +547,7 @@ describe("delete AMI's", () => {
590547
expect(mockSSMClient).toHaveReceivedCommandWith(DescribeParametersCommand, {
591548
ParameterFilters: [{ Key: 'Name', Option: 'Contains', Values: ['ami-id'] }],
592549
});
593-
expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersCommand, {
594-
Names: ['/some/path/ami-id'],
595-
WithDecryption: true,
596-
});
550+
expect(getParameters).toHaveBeenCalledWith(['/some/path/ami-id']);
597551
});
598552

599553
it('handles wildcard SSM parameter patterns (*ami_id)', async () => {
@@ -617,17 +571,9 @@ describe("delete AMI's", () => {
617571
],
618572
});
619573

620-
mockSSMClient.on(GetParametersCommand).resolves({
621-
Parameters: [
622-
{
623-
Name: '/github-runner/config/ami_id',
624-
Type: 'String',
625-
Value: 'ami-wildcard-underscore0001',
626-
Version: 1,
627-
},
628-
],
629-
InvalidParameters: [],
630-
});
574+
vi.mocked(getParameters).mockResolvedValue(
575+
new Map([['/github-runner/config/ami_id', 'ami-wildcard-underscore0001']]),
576+
);
631577

632578
await amiCleanup({
633579
minimumDaysOld: 0,
@@ -639,10 +585,7 @@ describe("delete AMI's", () => {
639585
expect(mockSSMClient).toHaveReceivedCommandWith(DescribeParametersCommand, {
640586
ParameterFilters: [{ Key: 'Name', Option: 'Contains', Values: ['ami_id'] }],
641587
});
642-
expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersCommand, {
643-
Names: ['/github-runner/config/ami_id'],
644-
WithDecryption: true,
645-
});
588+
expect(getParameters).toHaveBeenCalledWith(['/github-runner/config/ami_id']);
646589
});
647590

648591
it('handles mixed explicit names and wildcard patterns', async () => {
@@ -664,17 +607,9 @@ describe("delete AMI's", () => {
664607
],
665608
});
666609

667-
mockSSMClient.on(GetParametersCommand, { Names: ['/explicit/ami_id'], WithDecryption: true }).resolves({
668-
Parameters: [
669-
{
670-
Name: '/explicit/ami_id',
671-
Type: 'String',
672-
Value: 'ami-explicit0001',
673-
Version: 1,
674-
},
675-
],
676-
InvalidParameters: [],
677-
});
610+
vi.mocked(getParameters)
611+
.mockResolvedValueOnce(new Map([['/explicit/ami_id', 'ami-explicit0001']]))
612+
.mockResolvedValueOnce(new Map([['/discovered/ami-id', 'ami-wildcard0001']]));
678613

679614
mockSSMClient.on(DescribeParametersCommand).resolves({
680615
Parameters: [
@@ -686,18 +621,6 @@ describe("delete AMI's", () => {
686621
],
687622
});
688623

689-
mockSSMClient.on(GetParametersCommand, { Names: ['/discovered/ami-id'], WithDecryption: true }).resolves({
690-
Parameters: [
691-
{
692-
Name: '/discovered/ami-id',
693-
Type: 'String',
694-
Value: 'ami-wildcard0001',
695-
Version: 1,
696-
},
697-
],
698-
InvalidParameters: [],
699-
});
700-
701624
await amiCleanup({
702625
minimumDaysOld: 0,
703626
ssmParameterNames: ['/explicit/ami_id', '*ami-id'],
@@ -709,17 +632,11 @@ describe("delete AMI's", () => {
709632
ImageId: 'ami-unused0001',
710633
});
711634

712-
expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersCommand, {
713-
Names: ['/explicit/ami_id'],
714-
WithDecryption: true,
715-
});
635+
expect(getParameters).toHaveBeenCalledWith(['/explicit/ami_id']);
716636
expect(mockSSMClient).toHaveReceivedCommandWith(DescribeParametersCommand, {
717637
ParameterFilters: [{ Key: 'Name', Option: 'Contains', Values: ['ami-id'] }],
718638
});
719-
expect(mockSSMClient).toHaveReceivedCommandWith(GetParametersCommand, {
720-
Names: ['/discovered/ami-id'],
721-
WithDecryption: true,
722-
});
639+
expect(getParameters).toHaveBeenCalledWith(['/discovered/ami-id']);
723640
});
724641

725642
it('handles SSM parameter fetch failures gracefully', async () => {
@@ -733,7 +650,7 @@ describe("delete AMI's", () => {
733650
],
734651
});
735652

736-
mockSSMClient.on(GetParametersCommand).rejects(new Error('ParameterNotFound'));
653+
vi.mocked(getParameters).mockRejectedValue(new Error('ParameterNotFound'));
737654

738655
// Should not throw and should delete the AMI since SSM reference failed
739656
await amiCleanup({
@@ -791,7 +708,7 @@ describe("delete AMI's", () => {
791708
ImageId: 'ami-no-ssm0001',
792709
});
793710
expect(mockSSMClient).not.toHaveReceivedCommand(DescribeParametersCommand);
794-
expect(mockSSMClient).not.toHaveReceivedCommand(GetParametersCommand);
711+
expect(getParameters).not.toHaveBeenCalled();
795712
});
796713
});
797714
});

lambdas/functions/ami-housekeeper/src/ami.ts

Lines changed: 21 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import {
88
Filter,
99
Image,
1010
} from '@aws-sdk/client-ec2';
11-
import { GetParametersCommand, SSMClient, DescribeParametersCommand } from '@aws-sdk/client-ssm';
11+
import { SSMClient, DescribeParametersCommand } from '@aws-sdk/client-ssm';
1212
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
1313
import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util';
14+
import { getParameters } from '@aws-github-runner/aws-ssm-util';
1415

1516
const logger = createChildLogger('ami');
1617

@@ -184,56 +185,37 @@ async function deleteSnapshot(options: AmiCleanupOptions, amiDetails: Image, ec2
184185
}
185186

186187
/**
187-
* Resolves the values of multiple SSM parameters by their names in batched API calls.
188+
* Resolves the values of multiple SSM parameters by their names.
189+
* Delegates batching to the shared `getParameters` utility.
188190
* Doesn't fail on errors, but warns instead, as this process is best-effort.
189191
*
190192
* @param names - Array of SSM parameter names to resolve
191-
* @param ssmClient - Configured SSM client for making API calls
192-
* @returns Array of parameter values (may contain undefined for missing/failed parameters)
193+
* @returns Array of parameter values in the same order as input (undefined for missing/failed parameters)
193194
*/
194-
async function resolveSsmParameterValues(names: string[], ssmClient: SSMClient): Promise<(string | undefined)[]> {
195+
async function resolveSsmParameterValues(names: string[]): Promise<(string | undefined)[]> {
195196
if (names.length === 0) {
196197
return [];
197198
}
198199

199-
const results = new Map<string, string | undefined>();
200-
201-
// AWS SSM GetParameters API has a limit of 10 parameters per call
202-
const chunkSize = 10;
203-
for (let i = 0; i < names.length; i += chunkSize) {
204-
const chunk = names.slice(i, i + chunkSize);
205-
try {
206-
const response = await ssmClient.send(
207-
new GetParametersCommand({
208-
Names: chunk,
209-
WithDecryption: true,
210-
}),
211-
);
212-
213-
for (const param of response.Parameters ?? []) {
214-
if (param.Name) {
215-
results.set(param.Name, param.Value);
216-
}
217-
}
200+
try {
201+
const parameterMap = await getParameters(names);
218202

219-
// Log warnings for invalid parameters
220-
for (const invalidParam of response.InvalidParameters ?? []) {
203+
// Log warnings for parameters that couldn't be resolved
204+
for (const name of names) {
205+
if (!parameterMap.has(name)) {
221206
logger.warn(
222-
`Failed to resolve image id from SSM parameter ${invalidParam}: Parameter not found or access denied`,
207+
`Failed to resolve image id from SSM parameter ${name}: Parameter not found or access denied`,
223208
);
224-
results.set(invalidParam, undefined);
225-
}
226-
} catch (error: unknown) {
227-
logger.warn(`Failed to resolve image ids from SSM parameters ${chunk.join(', ')}`, { error });
228-
// Mark all parameters in this chunk as undefined
229-
for (const name of chunk) {
230-
results.set(name, undefined);
231209
}
232210
}
233-
}
234211

235-
// Return values in the same order as input names
236-
return names.map((name) => results.get(name));
212+
// Return values in the same order as input names
213+
return names.map((name) => parameterMap.get(name));
214+
} catch (error: unknown) {
215+
logger.warn(`Failed to resolve image ids from SSM parameters ${names.join(', ')}`, { error });
216+
// Mark all parameters as undefined on failure
217+
return names.map(() => undefined);
218+
}
237219
}
238220

239221
/**
@@ -307,7 +289,7 @@ async function getAmisReferedInSSM(options: AmiCleanupOptions): Promise<(string
307289
const wildcardPatterns = options.ssmParameterNames.filter((n) => n.startsWith('*'));
308290

309291
// Batch fetch explicit parameter values in chunks of 10 (AWS API limit)
310-
const explicitValuesPromise = resolveSsmParameterValues(explicitNames, ssmClient);
292+
const explicitValuesPromise = resolveSsmParameterValues(explicitNames);
311293

312294
// Handle wildcard patterns by first discovering matching parameters, then
313295
// fetching their values
@@ -332,7 +314,7 @@ async function getAmisReferedInSSM(options: AmiCleanupOptions): Promise<(string
332314
.map((param) => param.Name)
333315
.filter((name): name is string => name !== undefined);
334316

335-
return resolveSsmParameterValues(discoveredNames, ssmClient);
317+
return resolveSsmParameterValues(discoveredNames);
336318
} catch (e) {
337319
logger.warn('Failed to describe SSM parameters using wildcard patterns', { error: e });
338320
return [];

0 commit comments

Comments
 (0)