diff --git a/examples/prebuilt/README.md b/examples/prebuilt/README.md index 9acd4574ac..1a9e731797 100644 --- a/examples/prebuilt/README.md +++ b/examples/prebuilt/README.md @@ -7,6 +7,18 @@ This module shows how to create GitHub action runners using a prebuilt AMI for t @@ Usages + +Steps for the full setup, such as creating a GitHub app can be found in the root module's [README](https://github.com/github-aws-runners/terraform-aws-github-runner). First download the Lambda releases from GitHub. Alternatively you can build the lambdas locally with Node or Docker, there is a simple build script in `/.ci/build.sh`. In the `main.tf` you can simply remove the location of the lambda zip files, the default location will work in this case. + +> This example assumes local built lambda's available. Ensure you have built the lambda's. Alternatively you can download the lambda's. The version needs to be set to a GitHub release version, see https://github.com/github-aws-runners/terraform-aws-github-runner/releases + +```bash +cd ../lambdas-download +terraform init +terraform apply -var=module_version= +cd - +``` + ### Packer Image You will need to build your image. This example deployment uses the image example in `/images/linux-amz2`. You must build this image with packer in your AWS account first. Once you have built this you need to provider your owner ID as a variable @@ -92,6 +104,8 @@ terraform output webhook_secret | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [ami\_name\_filter](#input\_ami\_name\_filter) | AMI name filter for the action runner AMI. By default amazon linux 2 is used. | `string` | `"github-runner-al2023-x86_64-*"` | no | +| [aws\_region](#input\_aws\_region) | AWS region. | `string` | `"eu-west-1"` | no | +| [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no | | [github\_app](#input\_github\_app) | GitHub for API usages. |
object({
id = string
key_base64 = string
})
| n/a | yes | | [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux,windows). | `string` | `"linux"` | no | diff --git a/examples/prebuilt/main.tf b/examples/prebuilt/main.tf index dbfbdf9523..450d48c58f 100644 --- a/examples/prebuilt/main.tf +++ b/examples/prebuilt/main.tf @@ -1,6 +1,6 @@ locals { - environment = "prebuilt" - aws_region = "eu-west-1" + environment = var.environment != null ? var.environment : "default" + aws_region = var.aws_region } resource "random_id" "random" { @@ -32,9 +32,12 @@ module "runners" { webhook_secret = random_id.random.hex } - webhook_lambda_zip = "../lambdas-download/webhook.zip" - runner_binaries_syncer_lambda_zip = "../lambdas-download/runner-binaries-syncer.zip" - runners_lambda_zip = "../lambdas-download/runners.zip" + # link to downloaded lambda zip files. + # When not explicitly set lambda zip files are grabbed from the module requiring lambda build. + # + # webhook_lambda_zip = "../lambdas-download/webhook.zip" + # runner_binaries_syncer_lambda_zip = "../lambdas-download/runner-binaries-syncer.zip" + # runners_lambda_zip = "../lambdas-download/runners.zip" runner_extra_labels = ["default", "example"] @@ -56,6 +59,44 @@ module "runners" { # override scaling down scale_down_schedule_expression = "cron(* * * * ? *)" + + enable_ami_housekeeper = true + ami_housekeeper_cleanup_config = { + ssmParameterNames = ["*/ami_id"] + minimumDaysOld = 1 + dryRun = true + amiFilters = [ + { + Name = "name" + Values = ["*al2023*"] + } + ] + } + + # variable "runners_ssm_housekeeper" { + # description = < { expect(mockEC2Client).toHaveReceivedCommandTimes(DeregisterImageCommand, 1); expect(mockEC2Client).toHaveReceivedCommandTimes(DeleteSnapshotCommand, 1); }); + + it('should not delete AMIs referenced via resolve:ssm in launch templates.', async () => { + // The only AMI owned by the account and older than the age threshold + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [ + { + ImageId: 'ami-resolvesm0001', + CreationDate: date31DaysAgo.toISOString(), + }, + ], + }); + + // Launch template that ultimately resolves to the AMI ID via + // `resolve:ssm:`. Because the Lambda uses the EC2 `ResolveAlias` flag, the + // ImageId that we receive from the API will already be resolved to the real + // AMI ID. + mockEC2Client.on(DescribeLaunchTemplatesCommand).resolves({ + LaunchTemplates: [ + { + LaunchTemplateId: 'lt-resolve', + LaunchTemplateName: 'lt-resolve', + DefaultVersionNumber: 1, + LatestVersionNumber: 1, + }, + ], + }); + + mockEC2Client + .on(DescribeLaunchTemplateVersionsCommand, { + LaunchTemplateId: 'lt-resolve', + }) + .resolves({ + LaunchTemplateVersions: [ + { + LaunchTemplateId: 'lt-resolve', + LaunchTemplateName: 'lt-resolve', + VersionNumber: 1, + LaunchTemplateData: { + ImageId: 'ami-resolvesm0001', // resolved alias + }, + }, + ], + }); + + // Run cleanup with same age threshold to force consideration of the AMI + await amiCleanup({ + minimumDaysOld: 0, + launchTemplateNames: ['lt-resolve'], + }); + + expect(mockEC2Client).not.toHaveReceivedCommand(DeregisterImageCommand); + }); + + it('uses ResolveAlias flag in launch template version calls', async () => { + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [], + }); + + mockEC2Client.on(DescribeLaunchTemplatesCommand).resolves({ + LaunchTemplates: [ + { + LaunchTemplateId: 'lt-test', + LaunchTemplateName: 'lt-test', + DefaultVersionNumber: 1, + LatestVersionNumber: 1, + }, + ], + }); + + mockEC2Client.on(DescribeLaunchTemplateVersionsCommand).resolves({ + LaunchTemplateVersions: [ + { + LaunchTemplateId: 'lt-test', + LaunchTemplateName: 'lt-test', + VersionNumber: 1, + LaunchTemplateData: { + ImageId: 'ami-resolved', + }, + }, + ], + }); + + await amiCleanup({ + launchTemplateNames: ['lt-test'], + }); + + // Verify that ResolveAlias: true was passed to the command + expect(mockEC2Client).toHaveReceivedCommandWith(DescribeLaunchTemplateVersionsCommand, { + LaunchTemplateId: 'lt-test', + Versions: ['$Default'], + ResolveAlias: true, + }); + }); + + describe('SSM Parameter Handling', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockEC2Client.reset(); + mockSSMClient.reset(); + + // Default setup for launch templates (empty) + mockEC2Client.on(DescribeLaunchTemplatesCommand).resolves({ + LaunchTemplates: [], + }); + }); + + it('handles explicit SSM parameter names (ami_id with underscore)', async () => { + // Setup AMI that would be deleted if not referenced + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [ + { + ImageId: 'ami-underscore0001', + CreationDate: date31DaysAgo.toISOString(), + }, + ], + }); + + mockSSMClient.on(GetParameterCommand, { Name: '/github-runner/config/ami_id' }).resolves({ + Parameter: { + Name: '/github-runner/config/ami_id', + Type: 'String', + Value: 'ami-underscore0001', + Version: 1, + }, + }); + + await amiCleanup({ + minimumDaysOld: 0, + ssmParameterNames: ['/github-runner/config/ami_id'], + }); + + // AMI should not be deleted because it's referenced in SSM + expect(mockEC2Client).not.toHaveReceivedCommand(DeregisterImageCommand); + expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, { + Name: '/github-runner/config/ami_id', + }); + expect(mockSSMClient).not.toHaveReceivedCommand(DescribeParametersCommand); + }); + + it('handles explicit SSM parameter names (ami-id with hyphen)', async () => { + // AMI that would be deleted if not referenced + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [ + { + ImageId: 'ami-hyphen0001', + CreationDate: date31DaysAgo.toISOString(), + }, + ], + }); + + mockSSMClient.on(GetParameterCommand, { Name: '/github-runner/config/ami-id' }).resolves({ + Parameter: { + Name: '/github-runner/config/ami-id', + Type: 'String', + Value: 'ami-hyphen0001', + Version: 1, + }, + }); + + await amiCleanup({ + minimumDaysOld: 0, + ssmParameterNames: ['/github-runner/config/ami-id'], + }); + + // AMI should not be deleted because it's referenced in SSM + expect(mockEC2Client).not.toHaveReceivedCommand(DeregisterImageCommand); + expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, { + Name: '/github-runner/config/ami-id', + }); + expect(mockSSMClient).not.toHaveReceivedCommand(DescribeParametersCommand); + }); + + it('handles wildcard SSM parameter patterns (*ami-id)', async () => { + // AMI that would be deleted if not referenced + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [ + { + ImageId: 'ami-wildcard0001', + CreationDate: date31DaysAgo.toISOString(), + }, + ], + }); + + mockSSMClient.on(DescribeParametersCommand).resolves({ + Parameters: [ + { + Name: '/some/path/ami-id', + Type: 'String', + Version: 1, + }, + ], + }); + + mockSSMClient.on(GetParameterCommand, { Name: '/some/path/ami-id' }).resolves({ + Parameter: { + Name: '/some/path/ami-id', + Type: 'String', + Value: 'ami-wildcard0001', + Version: 1, + }, + }); + + await amiCleanup({ + minimumDaysOld: 0, + ssmParameterNames: ['*ami-id'], + }); + + // AMI should not be deleted because it's referenced in SSM + expect(mockEC2Client).not.toHaveReceivedCommand(DeregisterImageCommand); + expect(mockSSMClient).toHaveReceivedCommandWith(DescribeParametersCommand, { + ParameterFilters: [{ Key: 'Name', Option: 'Contains', Values: ['ami-id'] }], + }); + expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, { + Name: '/some/path/ami-id', + }); + }); + + it('handles wildcard SSM parameter patterns (*ami_id)', async () => { + // AMI that would be deleted if not referenced + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [ + { + ImageId: 'ami-wildcard-underscore0001', + CreationDate: date31DaysAgo.toISOString(), + }, + ], + }); + + mockSSMClient.on(DescribeParametersCommand).resolves({ + Parameters: [ + { + Name: '/github-runner/config/ami_id', + Type: 'String', + Version: 1, + }, + ], + }); + + mockSSMClient.on(GetParameterCommand, { Name: '/github-runner/config/ami_id' }).resolves({ + Parameter: { + Name: '/github-runner/config/ami_id', + Type: 'String', + Value: 'ami-wildcard-underscore0001', + Version: 1, + }, + }); + + await amiCleanup({ + minimumDaysOld: 0, + ssmParameterNames: ['*ami_id'], + }); + + // AMI should not be deleted because it's referenced in SSM + expect(mockEC2Client).not.toHaveReceivedCommand(DeregisterImageCommand); + expect(mockSSMClient).toHaveReceivedCommandWith(DescribeParametersCommand, { + ParameterFilters: [{ Key: 'Name', Option: 'Contains', Values: ['ami_id'] }], + }); + expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, { + Name: '/github-runner/config/ami_id', + }); + }); + + it('handles mixed explicit names and wildcard patterns', async () => { + // AMIs that would be deleted if not referenced + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [ + { + ImageId: 'ami-explicit0001', + CreationDate: date31DaysAgo.toISOString(), + }, + { + ImageId: 'ami-wildcard0001', + CreationDate: date31DaysAgo.toISOString(), + }, + { + ImageId: 'ami-unused0001', + CreationDate: date31DaysAgo.toISOString(), + }, + ], + }); + + mockSSMClient.on(GetParameterCommand, { Name: '/explicit/ami_id' }).resolves({ + Parameter: { + Name: '/explicit/ami_id', + Type: 'String', + Value: 'ami-explicit0001', + Version: 1, + }, + }); + + mockSSMClient.on(DescribeParametersCommand).resolves({ + Parameters: [ + { + Name: '/discovered/ami-id', + Type: 'String', + Version: 1, + }, + ], + }); + + mockSSMClient.on(GetParameterCommand, { Name: '/discovered/ami-id' }).resolves({ + Parameter: { + Name: '/discovered/ami-id', + Type: 'String', + Value: 'ami-wildcard0001', + Version: 1, + }, + }); + + await amiCleanup({ + minimumDaysOld: 0, + ssmParameterNames: ['/explicit/ami_id', '*ami-id'], + }); + + // Only the unused AMI should be deleted + expect(mockEC2Client).toHaveReceivedCommandTimes(DeregisterImageCommand, 1); + expect(mockEC2Client).toHaveReceivedCommandWith(DeregisterImageCommand, { + ImageId: 'ami-unused0001', + }); + + expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, { + Name: '/explicit/ami_id', + }); + expect(mockSSMClient).toHaveReceivedCommandWith(DescribeParametersCommand, { + ParameterFilters: [{ Key: 'Name', Option: 'Contains', Values: ['ami-id'] }], + }); + expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, { + Name: '/discovered/ami-id', + }); + }); + + it('handles SSM parameter fetch failures gracefully', async () => { + // AMI that would be deleted if not referenced + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [ + { + ImageId: 'ami-failure0001', + CreationDate: date31DaysAgo.toISOString(), + }, + ], + }); + + mockSSMClient.on(GetParameterCommand, { Name: '/nonexistent/ami_id' }).rejects(new Error('ParameterNotFound')); + + // Should not throw and should delete the AMI since SSM reference failed + await amiCleanup({ + minimumDaysOld: 0, + ssmParameterNames: ['/nonexistent/ami_id'], + }); + + expect(mockEC2Client).toHaveReceivedCommandWith(DeregisterImageCommand, { + ImageId: 'ami-failure0001', + }); + }); + + it('handles DescribeParameters failures gracefully for wildcards', async () => { + // AMI that would be deleted if not referenced + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [ + { + ImageId: 'ami-describe-failure0001', + CreationDate: date31DaysAgo.toISOString(), + }, + ], + }); + + mockSSMClient.on(DescribeParametersCommand).rejects(new Error('AccessDenied')); + + // Should not throw and should delete the AMI since SSM discovery failed + await amiCleanup({ + minimumDaysOld: 0, + ssmParameterNames: ['*ami-id'], + }); + + expect(mockEC2Client).toHaveReceivedCommandWith(DeregisterImageCommand, { + ImageId: 'ami-describe-failure0001', + }); + }); + + it('handles empty SSM parameter lists', async () => { + // AMI that should be deleted + mockEC2Client.on(DescribeImagesCommand, { Owners: ['self'] }).resolves({ + Images: [ + { + ImageId: 'ami-no-ssm0001', + CreationDate: date31DaysAgo.toISOString(), + }, + ], + }); + + await amiCleanup({ + minimumDaysOld: 0, + ssmParameterNames: [], + }); + + // AMI should be deleted since no SSM parameters are checked + expect(mockEC2Client).toHaveReceivedCommandWith(DeregisterImageCommand, { + ImageId: 'ami-no-ssm0001', + }); + expect(mockSSMClient).not.toHaveReceivedCommand(DescribeParametersCommand); + expect(mockSSMClient).not.toHaveReceivedCommand(GetParameterCommand); + }); + }); }); diff --git a/lambdas/functions/ami-housekeeper/src/ami.ts b/lambdas/functions/ami-housekeeper/src/ami.ts index c5f207b841..f61dea921c 100644 --- a/lambdas/functions/ami-housekeeper/src/ami.ts +++ b/lambdas/functions/ami-housekeeper/src/ami.ts @@ -8,7 +8,7 @@ import { Filter, Image, } from '@aws-sdk/client-ec2'; -import { DescribeParametersCommand, GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; +import { GetParameterCommand, SSMClient, DescribeParametersCommand } from '@aws-sdk/client-ssm'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; @@ -62,34 +62,56 @@ function applyDefaults(options: AmiCleanupOptions): AmiCleanupOptions { } /** - * Cleanup AMIs that are not in use anymore. + * Clean up old AMIs that are not actively used. * - * @param options the cleanup options + * 1. Identify AMIs that are not referenced in Launch Templates or SSM + * parameters + * 2. Keep AMIs newer than the specified age threshold + * 3. Delete the remaining AMIs and their associated snapshots + * + * @param options Configuration for the cleanup process */ async function amiCleanup(options: AmiCleanupOptions): Promise { const mergedOptions = applyDefaults(options) as AmiCleanupOptionsInternal; logger.info(`Cleaning up non used AMIs older then ${mergedOptions.minimumDaysOld} days`); logger.debug('Using the following options', { options: mergedOptions }); + // Identify AMIs that are safe to delete (not referenced anywhere) const amisNotInUse = await getAmisNotInUse(mergedOptions); + // Delete each AMI with a small delay to avoid overwhelming the API for (const image of amisNotInUse) { - await new Promise((resolve) => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); // Rate limiting await deleteAmi(image, mergedOptions); } } -async function getAmisNotInUse(options: AmiCleanupOptions) { +/** + * Filter out AMIs that are currently in use. + * + * 1. Discover AMIs referenced in SSM parameters (both explicit and wildcard + * patterns) + * 2. Discover AMIs referenced in Launch Templates + * 3. Get all account-owned AMIs matching the provided filters + * 4. Exclude AMIs from (1) and (2) + * + * @param options Configuration for the cleanup process + * @returns Array of AMI objects that are not referenced and eligible for + * deletion + */ +async function getAmisNotInUse(options: AmiCleanupOptions): Promise { + // Concurrently discover AMIs that are actively referenced and should be preserved const amiIdsInSSM = await getAmisReferedInSSM(options); const amiIdsInTemplates = await getAmiInLatestTemplates(options); + // Fetch all account-owned AMIs that match the specified filters const ec2Client = getTracedAWSV3Client(new EC2Client({})); logger.debug('Getting all AMIs from ec2 with filters', { filters: options.amiFilters }); const amiEc2 = await ec2Client.send( new DescribeImagesCommand({ - Owners: ['self'], + Owners: ['self'], // Only consider AMIs owned by this account MaxResults: options.maxItems ? options.maxItems : undefined, - Filters: options.amiFilters, + Filters: options.amiFilters, // Apply additional filters (e.g., state=available) }), ); logger.debug('Found the following AMIs', { amiEc2 }); @@ -102,10 +124,13 @@ async function getAmisNotInUse(options: AmiCleanupOptions) { return 0; } }); - logger.info(`found #${amiEc2.Images?.length} images in ec2`); + logger.info(`found #${amiEc2.Images?.length} images in ec2`); logger.info(`found #${amiIdsInSSM.length} images referenced in SSM`); logger.info(`found #${amiIdsInTemplates.length} images in latest versions of launch templates`); + + // Filter out AMIs that are referenced in either SSM parameters or Launch + // Templates. const filteredAmiEc2 = amiEc2.Images?.filter( (image) => !amiIdsInSSM.includes(image.ImageId) && !amiIdsInTemplates.includes(image.ImageId), @@ -158,61 +183,128 @@ async function deleteSnapshot(options: AmiCleanupOptions, amiDetails: Image, ec2 }); } +/** + * Resolves the value of an SSM parameter by its name. Doesn't fail on errors, + * but warns instead, as this process is best-effort. + * + * @param name - The SSM parameter name to resolve + * @param ssmClient - Configured SSM client for making API calls + * @returns The parameter value if successful, undefined if parameter doesn't exist or access fails + */ +async function resolveSsmParameterValue(name: string, ssmClient: SSMClient): Promise { + try { + const { Parameter: parameter } = await ssmClient.send(new GetParameterCommand({ Name: name })); + + return parameter?.Value; + } catch (error: unknown) { + logger.warn(`Failed to resolve image id from SSM parameter ${name}`, { error }); + + return undefined; + } +} + +/** + * Retrieve AMI IDs referenced in Launch Templates. + * + * Discover AMI IDs that are actively used in Launch Templates, which indicates + * they should not be cleaned up. + * + * @param options - Cleanup configuration including optional launch template name filters + * @returns Array of AMI IDs found in launch templates (may contain undefined values) + */ async function getAmiInLatestTemplates(options: AmiCleanupOptions): Promise<(string | undefined)[]> { const ec2Client = getTracedAWSV3Client(new EC2Client({})); - const launnchTemplates = await ec2Client.send( + + // Discover launch templates, optionally filtered by specific names. If no + // names provided, this will return all launch templates in the account + logger.debug('Describing launch templates', { + launchTemplateNames: options.launchTemplateNames, + }); + const launchTemplates = await ec2Client.send( new DescribeLaunchTemplatesCommand({ LaunchTemplateNames: options.launchTemplateNames, }), ); + logger.debug('Found launch templates', { launchTemplates }); - // lookup details of latest version of each launch template - const amiIdsInTemplates = await Promise.all( - launnchTemplates.LaunchTemplates?.map(async (launchTemplate) => { - const launchTemplateVersion = await ec2Client.send( + // For each template, fetch the default version and resolve any SSM aliases. + const amiIdsNested = await Promise.all( + (launchTemplates.LaunchTemplates ?? []).map(async (template) => { + const versionsResp = await ec2Client.send( new DescribeLaunchTemplateVersionsCommand({ - LaunchTemplateId: launchTemplate.LaunchTemplateId, - Versions: ['$Default'], + LaunchTemplateId: template.LaunchTemplateId, + Versions: ['$Default'], // Only check the default version + // This means that references like `resolve:ssm:` are + // dereferenced. + ResolveAlias: true, }), ); - return launchTemplateVersion.LaunchTemplateVersions?.map( - (templateVersion) => templateVersion.LaunchTemplateData?.ImageId, - ).flat(); - }) ?? [], + + logger.debug('Found launch template versions', { versionsResp }); + return (versionsResp.LaunchTemplateVersions ?? []).map((v) => v.LaunchTemplateData?.ImageId); + }), ); - return amiIdsInTemplates.flat(); + logger.debug('Found AMIs in launch templates', { amiIdsNested }); + return amiIdsNested.flat(); } +/** + * Retrieve AMI IDs referenced in SSM Parameters. + * + * Resolve AMI IDs stored in SSM parameters, supporting both literal parameter + * names and wildcard patterns. + * + * @param options - Cleanup configuration including SSM parameter names/patterns to check + * @returns Array of AMI IDs found in SSM parameters (may contain undefined values) + */ async function getAmisReferedInSSM(options: AmiCleanupOptions): Promise<(string | undefined)[]> { if (!options.ssmParameterNames || options.ssmParameterNames.length === 0) { return []; } const ssmClient = getTracedAWSV3Client(new SSMClient({})); - const ssmParams = await ssmClient.send( - new DescribeParametersCommand({ - ParameterFilters: [ - { - Key: 'Name', - Values: ['ami-id'], - Option: 'Contains', - }, - ], - }), - ); - logger.debug('Found the following SSM parameters', { ssmParams }); - return await Promise.all( - (ssmParams.Parameters ?? []).map(async (param) => { - const paramValue = await ssmClient.send( - new GetParameterCommand({ - Name: param.Name, - }), + // Categorise parameter names into two groups for different handling strategies: + // 1. Explicit names: Direct parameter lookups (e.g., + // "/github-runner/config/ami_id"). These can be looked up directly. + // 2. Wildcard patterns: Require parameter discovery first (e.g., "*ami-id", + // "*ami_id"). For these, we need to enumerate. + const explicitNames = options.ssmParameterNames.filter((n) => !n.startsWith('*')); + const wildcardPatterns = options.ssmParameterNames.filter((n) => n.startsWith('*')); + + const explicitValuesPromise = Promise.all(explicitNames.map((name) => resolveSsmParameterValue(name, ssmClient))); + + // Handle wildcard patterns by first discovering matching parameters, then + // fetching their values + let wildcardValues: Promise<(string | undefined)[]> = Promise.resolve([]); + if (wildcardPatterns.length > 0) { + // Convert wildcard patterns to SSM ParameterFilters using Contains logic + // Example: "*ami-id" becomes a filter for parameters containing "ami-id" + const filters = wildcardPatterns.map((p) => ({ + Key: 'Name', + Option: 'Contains', + Values: [p.replace(/^\*/g, '')], + })); + + try { + // Discover parameters matching the wildcard patterns + logger.debug('Describing SSM parameter', { filters }); + const ssmParameters = await ssmClient.send(new DescribeParametersCommand({ ParameterFilters: filters })); + + // Fetch the actual values of discovered parameters + wildcardValues = Promise.all( + (ssmParameters.Parameters ?? []).map((param) => resolveSsmParameterValue(param.Name!, ssmClient)), ); - return paramValue.Parameter?.Value; - }), - ); + } catch (e) { + logger.warn('Failed to describe SSM parameters using wildcard patterns', { error: e }); + } + } + + // Combine results from both explicit and wildcard parameter resolution + const values = await Promise.all([explicitValuesPromise, wildcardValues]); + logger.debug('Resolved SSM parameter values', { values }); + return values.flat(); } export { amiCleanup, getAmisNotInUse }; diff --git a/modules/ami-housekeeper/policies/lambda-ami-housekeeper.json b/modules/ami-housekeeper/policies/lambda-ami-housekeeper.json index d13355717c..282b23e7da 100644 --- a/modules/ami-housekeeper/policies/lambda-ami-housekeeper.json +++ b/modules/ami-housekeeper/policies/lambda-ami-housekeeper.json @@ -11,6 +11,7 @@ "ec2:DeregisterImage", "ec2:DeleteSnapshot", "ssm:DescribeParameters", + "ssm:GetParameters", "ssm:GetParameter" ], "Resource": "*"