diff --git a/lambdas/functions/ami-updater/.eslintrc.js b/lambdas/functions/ami-updater/.eslintrc.js new file mode 100644 index 0000000000..d736bda660 --- /dev/null +++ b/lambdas/functions/ami-updater/.eslintrc.js @@ -0,0 +1,20 @@ +module.exports = { + parser: '@typescript-eslint/parser', + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + ], + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + env: { + node: true, + es6: true, + }, + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, +}; \ No newline at end of file diff --git a/lambdas/functions/ami-updater/package.json b/lambdas/functions/ami-updater/package.json new file mode 100644 index 0000000000..337bfc54bb --- /dev/null +++ b/lambdas/functions/ami-updater/package.json @@ -0,0 +1,51 @@ +{ + "name": "@aws-github-runner/ami-updater", + "version": "1.0.0", + "main": "lambda.ts", + "type": "module", + "license": "MIT", + "scripts": { + "start": "ts-node-dev src/local.ts", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src", + "watch": "ts-node-dev --respawn --exit-child src/local.ts", + "build": "ncc build src/lambda.ts -o dist", + "dist": "yarn build && cp package.json dist/ && cd dist && zip ../ami-updater.zip *", + "format": "prettier --write \"**/*.ts\"", + "format-check": "prettier --check \"**/*.ts\"", + "all": "yarn build && yarn format && yarn lint && yarn test" + }, + "dependencies": { + "@aws-github-runner/aws-powertools-util": "*", + "@aws-github-runner/aws-ssm-util": "*", + "@aws-sdk/client-ec2": "^3.767.0", + "@aws-sdk/client-ssm": "^3.759.0" + }, + "devDependencies": { + "@aws-sdk/types": "^3.734.0", + "@types/aws-lambda": "^8.10.147", + "@types/node": "^20.10.4", + "@typescript-eslint/eslint-plugin": "^6.13.2", + "@typescript-eslint/parser": "^6.13.2", + "@vercel/ncc": "^0.38.3", + "aws-sdk-client-mock": "^4.1.0", + "aws-sdk-client-mock-jest": "^4.1.0", + "eslint": "^8.55.0", + "prettier": "^3.0.0", + "typescript": "^5.3.3", + "vitest": "^3.0.9" + }, + "nx": { + "includedScripts": [ + "build", + "dist", + "format", + "format-check", + "lint", + "start", + "watch", + "all" + ] + } +} \ No newline at end of file diff --git a/lambdas/functions/ami-updater/src/ami.test.ts b/lambdas/functions/ami-updater/src/ami.test.ts new file mode 100644 index 0000000000..4b2734d731 --- /dev/null +++ b/lambdas/functions/ami-updater/src/ami.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + EC2Client, + DescribeImagesCommand, + DescribeLaunchTemplatesCommand, + DescribeLaunchTemplateVersionsCommand, + CreateLaunchTemplateVersionCommand, + ModifyLaunchTemplateCommand, +} from '@aws-sdk/client-ec2'; +import { AMIManager } from './ami'; + +vi.mock('@aws-sdk/client-ec2'); +vi.mock('../../shared/aws-powertools-util', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe('AMIManager', () => { + let ec2Client: EC2Client; + let amiManager: AMIManager; + + beforeEach(() => { + ec2Client = new EC2Client({}); + amiManager = new AMIManager(ec2Client); + vi.clearAllMocks(); + }); + + describe('getLatestAmi', () => { + it('should return the latest AMI ID', async () => { + const mockResponse = { + Images: [ + { ImageId: 'ami-2', CreationDate: '2023-12-02' }, + { ImageId: 'ami-1', CreationDate: '2023-12-01' }, + ], + }; + + vi.mocked(ec2Client.send).mockResolvedValueOnce(mockResponse); + + const config = { + owners: ['self'], + filters: [{ Name: 'tag:Environment', Values: ['prod'] }], + }; + + const result = await amiManager.getLatestAmi(config); + expect(result).toBe('ami-2'); + expect(ec2Client.send).toHaveBeenCalledWith(expect.any(DescribeImagesCommand)); + }); + + it('should throw error when no AMIs found', async () => { + vi.mocked(ec2Client.send).mockResolvedValueOnce({ Images: [] }); + + const config = { + owners: ['self'], + filters: [{ Name: 'tag:Environment', Values: ['prod'] }], + }; + + await expect(amiManager.getLatestAmi(config)).rejects.toThrow('No matching AMIs found'); + }); + }); + + describe('updateLaunchTemplate', () => { + it('should update launch template with new AMI ID', async () => { + vi.mocked(ec2Client.send) + .mockResolvedValueOnce({ + // getCurrentAmiId - DescribeLaunchTemplatesCommand + LaunchTemplates: [{ LatestVersionNumber: 1 }], + }) + .mockResolvedValueOnce({ + // getCurrentAmiId - DescribeLaunchTemplateVersionsCommand + LaunchTemplateVersions: [{ LaunchTemplateData: { ImageId: 'ami-old' } }], + }) + .mockResolvedValueOnce({ + // updateLaunchTemplate - DescribeLaunchTemplatesCommand + LaunchTemplates: [{ LatestVersionNumber: 1 }], + }); + + const result = await amiManager.updateLaunchTemplate('test-template', 'ami-new', false); + + expect(result.success).toBe(true); + expect(result.message).toBe('Updated successfully'); + expect(ec2Client.send).toHaveBeenCalledWith(expect.any(CreateLaunchTemplateVersionCommand)); + expect(ec2Client.send).toHaveBeenCalledWith(expect.any(ModifyLaunchTemplateCommand)); + }); + + it('should not update if AMI ID is the same', async () => { + vi.mocked(ec2Client.send) + .mockResolvedValueOnce({ + // getCurrentAmiId - DescribeLaunchTemplatesCommand + LaunchTemplates: [{ LatestVersionNumber: 1 }], + }) + .mockResolvedValueOnce({ + // getCurrentAmiId - DescribeLaunchTemplateVersionsCommand + LaunchTemplateVersions: [{ LaunchTemplateData: { ImageId: 'ami-1' } }], + }); + + const result = await amiManager.updateLaunchTemplate('test-template', 'ami-1', false); + + expect(result.success).toBe(true); + expect(result.message).toBe('Already using latest AMI'); + expect(ec2Client.send).not.toHaveBeenCalledWith(expect.any(CreateLaunchTemplateVersionCommand)); + }); + + it('should handle dry run mode', async () => { + vi.mocked(ec2Client.send) + .mockResolvedValueOnce({ + // getCurrentAmiId - DescribeLaunchTemplatesCommand + LaunchTemplates: [{ LatestVersionNumber: 1 }], + }) + .mockResolvedValueOnce({ + // getCurrentAmiId - DescribeLaunchTemplateVersionsCommand + LaunchTemplateVersions: [{ LaunchTemplateData: { ImageId: 'ami-old' } }], + }); + + const result = await amiManager.updateLaunchTemplate('test-template', 'ami-new', true); + + expect(result.success).toBe(true); + expect(result.message).toBe('Would update AMI (Dry Run)'); + expect(ec2Client.send).not.toHaveBeenCalledWith(expect.any(CreateLaunchTemplateVersionCommand)); + }); + }); +}); diff --git a/lambdas/functions/ami-updater/src/ami.ts b/lambdas/functions/ami-updater/src/ami.ts new file mode 100644 index 0000000000..b0b757ae39 --- /dev/null +++ b/lambdas/functions/ami-updater/src/ami.ts @@ -0,0 +1,141 @@ +import { + EC2Client, + DescribeImagesCommand, + DescribeLaunchTemplatesCommand, + DescribeLaunchTemplateVersionsCommand, + CreateLaunchTemplateVersionCommand, + ModifyLaunchTemplateCommand, + Image, + Filter, +} from '@aws-sdk/client-ec2'; +import { logger } from '@aws-github-runner/aws-powertools-util'; + +export interface AMIFilterConfig { + owners: string[]; + filters: Filter[]; +} + +export class AMIManager { + constructor(private readonly ec2Client: EC2Client) {} + + async getLatestAmi(config: AMIFilterConfig): Promise { + try { + const response = await this.ec2Client.send( + new DescribeImagesCommand({ + Owners: config.owners, + Filters: config.filters, + }), + ); + + if (!response.Images || response.Images.length === 0) { + throw new Error('No matching AMIs found'); + } + + // Sort by creation date to get the latest + const sortedImages = response.Images.sort((a: Image, b: Image) => { + return (b.CreationDate || '').localeCompare(a.CreationDate || ''); + }); + + if (!sortedImages[0].ImageId) { + throw new Error('Latest AMI has no ImageId'); + } + + return sortedImages[0].ImageId; + } catch (error) { + logger.error('Error getting latest AMI', { error }); + throw error; + } + } + + async getCurrentAmiId(templateName: string): Promise { + try { + const response = await this.ec2Client.send( + new DescribeLaunchTemplatesCommand({ + LaunchTemplateNames: [templateName], + }), + ); + + if (!response.LaunchTemplates || response.LaunchTemplates.length === 0) { + logger.warn(`Launch template ${templateName} not found`); + return null; + } + + const latestVersion = response.LaunchTemplates[0].LatestVersionNumber?.toString(); + if (!latestVersion) { + logger.warn('No latest version found for launch template'); + return null; + } + + const templateData = await this.ec2Client.send( + new DescribeLaunchTemplateVersionsCommand({ + LaunchTemplateName: templateName, + Versions: [latestVersion], + }), + ); + + return templateData.LaunchTemplateVersions?.[0]?.LaunchTemplateData?.ImageId || null; + } catch (error) { + logger.error(`Error getting current AMI ID for ${templateName}`, { error }); + return null; + } + } + + async updateLaunchTemplate( + templateName: string, + amiId: string, + dryRun: boolean, + ): Promise<{ success: boolean; message: string }> { + try { + const currentAmi = await this.getCurrentAmiId(templateName); + if (!currentAmi) { + return { success: false, message: 'Failed to get current AMI ID' }; + } + + if (currentAmi === amiId) { + logger.info(`Template ${templateName} already using latest AMI ${amiId}`); + return { success: true, message: 'Already using latest AMI' }; + } + + if (dryRun) { + logger.info(`[DRY RUN] Would update template ${templateName} from AMI ${currentAmi} to ${amiId}`); + return { success: true, message: 'Would update AMI (Dry Run)' }; + } + + // Get the latest version of the launch template + const response = await this.ec2Client.send( + new DescribeLaunchTemplatesCommand({ + LaunchTemplateNames: [templateName], + }), + ); + + if (!response.LaunchTemplates || response.LaunchTemplates.length === 0) { + logger.warn(`Launch template ${templateName} not found`); + return { success: false, message: 'Template not found' }; + } + + // Create new version with updated AMI ID + await this.ec2Client.send( + new CreateLaunchTemplateVersionCommand({ + LaunchTemplateName: templateName, + SourceVersion: response.LaunchTemplates[0].LatestVersionNumber?.toString(), + LaunchTemplateData: { ImageId: amiId }, + }), + ); + + // Set the new version as default + await this.ec2Client.send( + new ModifyLaunchTemplateCommand({ + LaunchTemplateName: templateName, + DefaultVersion: '$Latest', + }), + ); + + logger.info(`Successfully updated launch template ${templateName} from AMI ${currentAmi} to ${amiId}`); + return { success: true, message: 'Updated successfully' }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`Error updating launch template ${templateName}`, { error }); + return { success: false, message: `Error: ${errorMessage}` }; + } + } +} diff --git a/lambdas/functions/ami-updater/src/config.test.ts b/lambdas/functions/ami-updater/src/config.test.ts new file mode 100644 index 0000000000..365484ff24 --- /dev/null +++ b/lambdas/functions/ami-updater/src/config.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getConfig } from './config'; + +vi.mock('../../shared/aws-powertools-util', () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe('getConfig', () => { + beforeEach(() => { + vi.resetModules(); + process.env = {}; + }); + + it('should return valid configuration when all environment variables are set correctly', () => { + process.env.LAUNCH_TEMPLATE_NAME = 'test-template'; + process.env.DRY_RUN = 'true'; + process.env.AMI_FILTER = JSON.stringify({ + owners: ['self'], + filters: [{ name: 'tag:Environment', values: ['prod'] }], + }); + + const config = getConfig(); + + expect(config).toEqual({ + launchTemplateName: 'test-template', + dryRun: true, + amiFilter: { + owners: ['self'], + filters: [{ name: 'tag:Environment', values: ['prod'] }], + }, + }); + }); + + it('should handle DRY_RUN=false correctly', () => { + process.env.LAUNCH_TEMPLATE_NAME = 'test-template'; + process.env.DRY_RUN = 'false'; + process.env.AMI_FILTER = JSON.stringify({ + owners: ['self'], + filters: [{ name: 'tag:Environment', values: ['prod'] }], + }); + + const config = getConfig(); + expect(config.dryRun).toBe(false); + }); + + it('should throw error when LAUNCH_TEMPLATE_NAME is not set', () => { + process.env.AMI_FILTER = JSON.stringify({ + owners: ['self'], + filters: [{ name: 'tag:Environment', values: ['prod'] }], + }); + + expect(() => getConfig()).toThrow('LAUNCH_TEMPLATE_NAME environment variable is not set'); + }); + + it('should throw error when AMI_FILTER is not set', () => { + process.env.LAUNCH_TEMPLATE_NAME = 'test-template'; + + expect(() => getConfig()).toThrow('AMI_FILTER environment variable is not set'); + }); + + it('should throw error when AMI_FILTER is invalid JSON', () => { + process.env.LAUNCH_TEMPLATE_NAME = 'test-template'; + process.env.AMI_FILTER = 'invalid-json'; + + expect(() => getConfig()).toThrow('Invalid AMI_FILTER format'); + }); + + it('should throw error when AMI_FILTER has invalid structure', () => { + process.env.LAUNCH_TEMPLATE_NAME = 'test-template'; + process.env.AMI_FILTER = JSON.stringify({ + owners: 'not-an-array', + filters: 'not-an-array', + }); + + expect(() => getConfig()).toThrow('AMI_FILTER must contain owners (array) and filters (array)'); + }); +}); diff --git a/lambdas/functions/ami-updater/src/config.ts b/lambdas/functions/ami-updater/src/config.ts new file mode 100644 index 0000000000..7ce5d2ee14 --- /dev/null +++ b/lambdas/functions/ami-updater/src/config.ts @@ -0,0 +1,41 @@ +import { logger } from '@aws-github-runner/aws-powertools-util'; + +import { AMIFilterConfig } from './ami'; + +export interface Config { + launchTemplateName: string; + dryRun: boolean; + amiFilter: AMIFilterConfig; +} + +export function getConfig(): Config { + const launchTemplateName = process.env.LAUNCH_TEMPLATE_NAME; + if (!launchTemplateName) { + throw new Error('LAUNCH_TEMPLATE_NAME environment variable is not set'); + } + + const amiFilterStr = process.env.AMI_FILTER; + if (!amiFilterStr) { + throw new Error('AMI_FILTER environment variable is not set'); + } + + let amiFilter: AMIFilterConfig; + try { + amiFilter = JSON.parse(amiFilterStr); + } catch (error) { + logger.error('Failed to parse AMI_FILTER', { error, amiFilterStr }); + throw new Error('Invalid AMI_FILTER format'); + } + + if (!Array.isArray(amiFilter.owners) || !Array.isArray(amiFilter.filters)) { + throw new Error('AMI_FILTER must contain owners (array) and filters (array)'); + } + + const dryRun = process.env.DRY_RUN?.toLowerCase() === 'true'; + + return { + launchTemplateName, + dryRun, + amiFilter, + }; +} \ No newline at end of file diff --git a/lambdas/functions/ami-updater/src/index.test.ts b/lambdas/functions/ami-updater/src/index.test.ts new file mode 100644 index 0000000000..61b06f8794 --- /dev/null +++ b/lambdas/functions/ami-updater/src/index.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handler } from './lambda'; +import { AMIManager } from './ami'; +import { getConfig } from './config'; + +vi.mock('./ami'); +vi.mock('./config'); +vi.mock('@aws-github-runner/aws-powertools-util', () => ({ + logger: { + addContext: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); + +describe('Lambda Handler', () => { + const mockContext = { + awsRequestId: 'test-request-id', + functionName: 'test-function', + }; + + const mockConfig = { + launchTemplateName: 'test-template', + dryRun: false, + amiFilter: { + owners: ['self'], + filters: [{ Name: 'tag:Environment', Values: ['prod'] }], + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getConfig).mockReturnValue(mockConfig); + }); + + it('should successfully update AMI', async () => { + vi.mocked(AMIManager.prototype.getLatestAmi).mockResolvedValue('ami-new'); + vi.mocked(AMIManager.prototype.updateLaunchTemplate).mockResolvedValue({ + success: true, + message: 'Updated successfully', + }); + + await handler({}, mockContext); + + expect(AMIManager.prototype.getLatestAmi).toHaveBeenCalledWith(mockConfig.amiFilter); + expect(AMIManager.prototype.updateLaunchTemplate).toHaveBeenCalledWith( + mockConfig.launchTemplateName, + 'ami-new', + mockConfig.dryRun, + ); + }); + + it('should handle failed AMI update', async () => { + vi.mocked(AMIManager.prototype.getLatestAmi).mockResolvedValue('ami-new'); + vi.mocked(AMIManager.prototype.updateLaunchTemplate).mockResolvedValue({ + success: false, + message: 'Update failed', + }); + + await expect(handler({}, mockContext)).rejects.toThrow('Update failed'); + }); + + it('should handle errors in getLatestAmi', async () => { + const error = new Error('Failed to get AMI'); + vi.mocked(AMIManager.prototype.getLatestAmi).mockRejectedValue(error); + + await expect(handler({}, mockContext)).rejects.toThrow('Failed to get AMI'); + }); + + it('should handle errors in updateLaunchTemplate', async () => { + vi.mocked(AMIManager.prototype.getLatestAmi).mockResolvedValue('ami-new'); + const error = new Error('Failed to update template'); + vi.mocked(AMIManager.prototype.updateLaunchTemplate).mockRejectedValue(error); + + await expect(handler({}, mockContext)).rejects.toThrow('Failed to update template'); + }); +}); diff --git a/lambdas/functions/ami-updater/src/index.ts b/lambdas/functions/ami-updater/src/index.ts new file mode 100644 index 0000000000..2d7147dab3 --- /dev/null +++ b/lambdas/functions/ami-updater/src/index.ts @@ -0,0 +1,32 @@ +import { EC2Client } from '@aws-sdk/client-ec2'; +import { logger, metrics } from '@aws-github-runner/aws-powertools-util'; +import { Context } from 'aws-lambda'; +import { AMIManager } from './ami'; +import { getConfig } from './config'; + +const ec2Client = new EC2Client({}); +const amiManager = new AMIManager(ec2Client); + +export const handler = async (_event: any, context: Context): Promise => { + try { + logger.addContext(context); + const config = getConfig(); + + logger.info('Starting AMI update process', { config }); + + const latestAmiId = await amiManager.getLatestAmi(config.amiFilter); + logger.info('Found latest AMI', { amiId: latestAmiId }); + + const result = await amiManager.updateLaunchTemplate(config.launchTemplateName, latestAmiId, config.dryRun); + + if (result.success) { + logger.info('AMI update completed successfully', { result }); + } else { + logger.error('AMI update failed', { result }); + throw new Error(result.message); + } + } catch (error) { + logger.error('Error in AMI update process', { error }); + throw error; + } +}; diff --git a/lambdas/functions/ami-updater/src/lambda.ts b/lambdas/functions/ami-updater/src/lambda.ts new file mode 100644 index 0000000000..2d7147dab3 --- /dev/null +++ b/lambdas/functions/ami-updater/src/lambda.ts @@ -0,0 +1,32 @@ +import { EC2Client } from '@aws-sdk/client-ec2'; +import { logger, metrics } from '@aws-github-runner/aws-powertools-util'; +import { Context } from 'aws-lambda'; +import { AMIManager } from './ami'; +import { getConfig } from './config'; + +const ec2Client = new EC2Client({}); +const amiManager = new AMIManager(ec2Client); + +export const handler = async (_event: any, context: Context): Promise => { + try { + logger.addContext(context); + const config = getConfig(); + + logger.info('Starting AMI update process', { config }); + + const latestAmiId = await amiManager.getLatestAmi(config.amiFilter); + logger.info('Found latest AMI', { amiId: latestAmiId }); + + const result = await amiManager.updateLaunchTemplate(config.launchTemplateName, latestAmiId, config.dryRun); + + if (result.success) { + logger.info('AMI update completed successfully', { result }); + } else { + logger.error('AMI update failed', { result }); + throw new Error(result.message); + } + } catch (error) { + logger.error('Error in AMI update process', { error }); + throw error; + } +}; diff --git a/lambdas/functions/ami-updater/src/local.ts b/lambdas/functions/ami-updater/src/local.ts new file mode 100644 index 0000000000..c121fa0f4d --- /dev/null +++ b/lambdas/functions/ami-updater/src/local.ts @@ -0,0 +1,32 @@ +import { handler } from './lambda'; +import { Context } from 'aws-lambda'; + +async function localRun() { + const mockEvent = { + // Add mock event data here + }; + + const mockContext: Context = { + awsRequestId: 'local-test', + functionName: 'ami-updater', + callbackWaitsForEmptyEventLoop: false, + functionVersion: '$LATEST', + invokedFunctionArn: 'local', + memoryLimitInMB: '128', + logGroupName: 'local', + logStreamName: 'local', + getRemainingTimeInMillis: () => 1000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + }; + + try { + const result = await handler(mockEvent, mockContext); + console.log('Result:', JSON.stringify(result, null, 2)); + } catch (error) { + console.error('Error:', error); + } +} + +localRun(); diff --git a/lambdas/functions/ami-updater/tsconfig.json b/lambdas/functions/ami-updater/tsconfig.json new file mode 100644 index 0000000000..cf4929ba26 --- /dev/null +++ b/lambdas/functions/ami-updater/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src/**/*" + ], + "exclude": [ + "src/**/*.test.ts" + ] +} \ No newline at end of file diff --git a/lambdas/functions/ami-updater/vitest.config.ts b/lambdas/functions/ami-updater/vitest.config.ts new file mode 100644 index 0000000000..c634ddbfd9 --- /dev/null +++ b/lambdas/functions/ami-updater/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}); diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 81e6ce96c5..77519b1af3 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -124,6 +124,29 @@ __metadata: languageName: unknown linkType: soft +"@aws-github-runner/ami-updater@workspace:functions/ami-updater": + version: 0.0.0-use.local + resolution: "@aws-github-runner/ami-updater@workspace:functions/ami-updater" + dependencies: + "@aws-github-runner/aws-powertools-util": "npm:*" + "@aws-github-runner/aws-ssm-util": "npm:*" + "@aws-sdk/client-ec2": "npm:^3.767.0" + "@aws-sdk/client-ssm": "npm:^3.759.0" + "@aws-sdk/types": "npm:^3.734.0" + "@types/aws-lambda": "npm:^8.10.147" + "@types/node": "npm:^20.10.4" + "@typescript-eslint/eslint-plugin": "npm:^6.13.2" + "@typescript-eslint/parser": "npm:^6.13.2" + "@vercel/ncc": "npm:^0.38.3" + aws-sdk-client-mock: "npm:^4.1.0" + aws-sdk-client-mock-jest: "npm:^4.1.0" + eslint: "npm:^8.55.0" + prettier: "npm:^3.0.0" + typescript: "npm:^5.3.3" + vitest: "npm:^3.0.9" + languageName: unknown + linkType: soft + "@aws-github-runner/aws-powertools-util@npm:*, @aws-github-runner/aws-powertools-util@workspace:libs/aws-powertools-util": version: 0.0.0-use.local resolution: "@aws-github-runner/aws-powertools-util@workspace:libs/aws-powertools-util" @@ -2911,6 +2934,13 @@ __metadata: languageName: node linkType: hard +"@eslint-community/regexpp@npm:^4.5.1": + version: 4.12.1 + resolution: "@eslint-community/regexpp@npm:4.12.1" + checksum: 10c0/a03d98c246bcb9109aec2c08e4d10c8d010256538dcb3f56610191607214523d4fb1b00aa81df830b6dffb74c5fa0be03642513a289c567949d3e550ca11cdf6 + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -5097,6 +5127,13 @@ __metadata: languageName: node linkType: hard +"@types/json-schema@npm:^7.0.12": + version: 7.0.15 + resolution: "@types/json-schema@npm:7.0.15" + checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db + languageName: node + linkType: hard + "@types/mime@npm:*": version: 3.0.1 resolution: "@types/mime@npm:3.0.1" @@ -5120,6 +5157,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.10.4": + version: 20.17.24 + resolution: "@types/node@npm:20.17.24" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10c0/2a39ce4c4cd4588a05b2a485cc0a1407cbea608dd1ab03e36add59d61712718d95c84b492ca5190753f0be2bce748aeeb0f2a1412e712775462befe3820b3ff9 + languageName: node + linkType: hard + "@types/node@npm:^22.13.10": version: 22.13.10 resolution: "@types/node@npm:22.13.10" @@ -5162,6 +5208,13 @@ __metadata: languageName: node linkType: hard +"@types/semver@npm:^7.5.0": + version: 7.5.8 + resolution: "@types/semver@npm:7.5.8" + checksum: 10c0/8663ff927234d1c5fcc04b33062cb2b9fcfbe0f5f351ed26c4d1e1581657deebd506b41ff7fdf89e787e3d33ce05854bc01686379b89e9c49b564c4cfa988efa + languageName: node + linkType: hard + "@types/send@npm:*": version: 0.17.4 resolution: "@types/send@npm:0.17.4" @@ -5249,6 +5302,31 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/eslint-plugin@npm:^6.13.2": + version: 6.21.0 + resolution: "@typescript-eslint/eslint-plugin@npm:6.21.0" + dependencies: + "@eslint-community/regexpp": "npm:^4.5.1" + "@typescript-eslint/scope-manager": "npm:6.21.0" + "@typescript-eslint/type-utils": "npm:6.21.0" + "@typescript-eslint/utils": "npm:6.21.0" + "@typescript-eslint/visitor-keys": "npm:6.21.0" + debug: "npm:^4.3.4" + graphemer: "npm:^1.4.0" + ignore: "npm:^5.2.4" + natural-compare: "npm:^1.4.0" + semver: "npm:^7.5.4" + ts-api-utils: "npm:^1.0.1" + peerDependencies: + "@typescript-eslint/parser": ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/f911a79ee64d642f814a3b6cdb0d324b5f45d9ef955c5033e78903f626b7239b4aa773e464a38c3e667519066169d983538f2bf8e5d00228af587c9d438fb344 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^8.26.1": version: 8.26.1 resolution: "@typescript-eslint/eslint-plugin@npm:8.26.1" @@ -5270,6 +5348,24 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/parser@npm:^6.13.2": + version: 6.21.0 + resolution: "@typescript-eslint/parser@npm:6.21.0" + dependencies: + "@typescript-eslint/scope-manager": "npm:6.21.0" + "@typescript-eslint/types": "npm:6.21.0" + "@typescript-eslint/typescript-estree": "npm:6.21.0" + "@typescript-eslint/visitor-keys": "npm:6.21.0" + debug: "npm:^4.3.4" + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/a8f99820679decd0d115c0af61903fb1de3b1b5bec412dc72b67670bf636de77ab07f2a68ee65d6da7976039bbf636907f9d5ca546db3f0b98a31ffbc225bc7d + languageName: node + linkType: hard + "@typescript-eslint/parser@npm:^8.26.1": version: 8.26.1 resolution: "@typescript-eslint/parser@npm:8.26.1" @@ -5286,6 +5382,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/scope-manager@npm:6.21.0" + dependencies: + "@typescript-eslint/types": "npm:6.21.0" + "@typescript-eslint/visitor-keys": "npm:6.21.0" + checksum: 10c0/eaf868938d811cbbea33e97e44ba7050d2b6892202cea6a9622c486b85ab1cf801979edf78036179a8ba4ac26f1dfdf7fcc83a68c1ff66be0b3a8e9a9989b526 + languageName: node + linkType: hard + "@typescript-eslint/scope-manager@npm:8.26.1": version: 8.26.1 resolution: "@typescript-eslint/scope-manager@npm:8.26.1" @@ -5296,6 +5402,23 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/type-utils@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/type-utils@npm:6.21.0" + dependencies: + "@typescript-eslint/typescript-estree": "npm:6.21.0" + "@typescript-eslint/utils": "npm:6.21.0" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^1.0.1" + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/7409c97d1c4a4386b488962739c4f1b5b04dc60cf51f8cd88e6b12541f84d84c6b8b67e491a147a2c95f9ec486539bf4519fb9d418411aef6537b9c156468117 + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.26.1": version: 8.26.1 resolution: "@typescript-eslint/type-utils@npm:8.26.1" @@ -5311,6 +5434,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/types@npm:6.21.0" + checksum: 10c0/020631d3223bbcff8a0da3efbdf058220a8f48a3de221563996ad1dcc30d6c08dadc3f7608cc08830d21c0d565efd2db19b557b9528921c78aabb605eef2d74d + languageName: node + linkType: hard + "@typescript-eslint/types@npm:8.26.1": version: 8.26.1 resolution: "@typescript-eslint/types@npm:8.26.1" @@ -5318,6 +5448,25 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/typescript-estree@npm:6.21.0" + dependencies: + "@typescript-eslint/types": "npm:6.21.0" + "@typescript-eslint/visitor-keys": "npm:6.21.0" + debug: "npm:^4.3.4" + globby: "npm:^11.1.0" + is-glob: "npm:^4.0.3" + minimatch: "npm:9.0.3" + semver: "npm:^7.5.4" + ts-api-utils: "npm:^1.0.1" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/af1438c60f080045ebb330155a8c9bb90db345d5069cdd5d01b67de502abb7449d6c75500519df829f913a6b3f490ade3e8215279b6bdc63d0fb0ae61034df5f + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:8.26.1": version: 8.26.1 resolution: "@typescript-eslint/typescript-estree@npm:8.26.1" @@ -5336,6 +5485,23 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/utils@npm:6.21.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.4.0" + "@types/json-schema": "npm:^7.0.12" + "@types/semver": "npm:^7.5.0" + "@typescript-eslint/scope-manager": "npm:6.21.0" + "@typescript-eslint/types": "npm:6.21.0" + "@typescript-eslint/typescript-estree": "npm:6.21.0" + semver: "npm:^7.5.4" + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + checksum: 10c0/ab2df3833b2582d4e5467a484d08942b4f2f7208f8e09d67de510008eb8001a9b7460f2f9ba11c12086fd3cdcac0c626761c7995c2c6b5657d5fa6b82030a32d + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:8.26.1": version: 8.26.1 resolution: "@typescript-eslint/utils@npm:8.26.1" @@ -5351,6 +5517,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/visitor-keys@npm:6.21.0" + dependencies: + "@typescript-eslint/types": "npm:6.21.0" + eslint-visitor-keys: "npm:^3.4.1" + checksum: 10c0/7395f69739cfa1cb83c1fb2fad30afa2a814756367302fb4facd5893eff66abc807e8d8f63eba94ed3b0fe0c1c996ac9a1680bcbf0f83717acedc3f2bb724fbf + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:8.26.1": version: 8.26.1 resolution: "@typescript-eslint/visitor-keys@npm:8.26.1" @@ -5769,6 +5945,13 @@ __metadata: languageName: node linkType: hard +"array-union@npm:^2.1.0": + version: 2.1.0 + resolution: "array-union@npm:2.1.0" + checksum: 10c0/429897e68110374f39b771ec47a7161fc6a8fc33e196857c0a396dc75df0b5f65e4d046674db764330b6bb66b39ef48dd7c53b6a2ee75cfb0681e0c1a7033962 + languageName: node + linkType: hard + "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -6640,6 +6823,15 @@ __metadata: languageName: node linkType: hard +"dir-glob@npm:^3.0.1": + version: 3.0.1 + resolution: "dir-glob@npm:3.0.1" + dependencies: + path-type: "npm:^4.0.0" + checksum: 10c0/dcac00920a4d503e38bb64001acb19df4efc14536ada475725e12f52c16777afdee4db827f55f13a908ee7efc0cb282e2e3dbaeeb98c0993dd93d1802d3bf00c + languageName: node + linkType: hard + "doctrine@npm:^3.0.0": version: 3.0.0 resolution: "doctrine@npm:3.0.0" @@ -6981,7 +7173,7 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^8.57.1": +"eslint@npm:^8.55.0, eslint@npm:^8.57.1": version: 8.57.1 resolution: "eslint@npm:8.57.1" dependencies: @@ -7199,6 +7391,19 @@ __metadata: languageName: node linkType: hard +"fast-glob@npm:^3.2.9": + version: 3.3.3 + resolution: "fast-glob@npm:3.3.3" + dependencies: + "@nodelib/fs.stat": "npm:^2.0.2" + "@nodelib/fs.walk": "npm:^1.2.3" + glob-parent: "npm:^5.1.2" + merge2: "npm:^1.3.0" + micromatch: "npm:^4.0.8" + checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe + languageName: node + linkType: hard + "fast-glob@npm:^3.3.2": version: 3.3.2 resolution: "fast-glob@npm:3.3.2" @@ -7595,6 +7800,20 @@ __metadata: languageName: node linkType: hard +"globby@npm:^11.1.0": + version: 11.1.0 + resolution: "globby@npm:11.1.0" + dependencies: + array-union: "npm:^2.1.0" + dir-glob: "npm:^3.0.1" + fast-glob: "npm:^3.2.9" + ignore: "npm:^5.2.0" + merge2: "npm:^1.4.1" + slash: "npm:^3.0.0" + checksum: 10c0/b39511b4afe4bd8a7aead3a27c4ade2b9968649abab0a6c28b1a90141b96ca68ca5db1302f7c7bd29eab66bf51e13916b8e0a3d0ac08f75e1e84a39b35691189 + languageName: node + linkType: hard + "gopd@npm:^1.0.1": version: 1.0.1 resolution: "gopd@npm:1.0.1" @@ -7777,6 +7996,13 @@ __metadata: languageName: node linkType: hard +"ignore@npm:^5.2.4": + version: 5.3.2 + resolution: "ignore@npm:5.3.2" + checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" @@ -8517,7 +8743,7 @@ __metadata: languageName: node linkType: hard -"merge2@npm:^1.3.0": +"merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb @@ -8531,7 +8757,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.4": +"micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -9355,7 +9581,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.5.3": +"prettier@npm:^3.0.0, prettier@npm:^3.5.3": version: 3.5.3 resolution: "prettier@npm:3.5.3" bin: @@ -9811,7 +10037,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.6.3": +"semver@npm:^7.5.4, semver@npm:^7.6.3": version: 7.7.1 resolution: "semver@npm:7.7.1" bin: @@ -10347,6 +10573,15 @@ __metadata: languageName: node linkType: hard +"ts-api-utils@npm:^1.0.1": + version: 1.4.3 + resolution: "ts-api-utils@npm:1.4.3" + peerDependencies: + typescript: ">=4.2.0" + checksum: 10c0/e65dc6e7e8141140c23e1dc94984bf995d4f6801919c71d6dc27cf0cd51b100a91ffcfe5217626193e5bea9d46831e8586febdc7e172df3f1091a7384299e23a + languageName: node + linkType: hard + "ts-api-utils@npm:^2.0.1": version: 2.0.1 resolution: "ts-api-utils@npm:2.0.1" @@ -10522,7 +10757,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.8.2": +"typescript@npm:^5.3.3, typescript@npm:^5.8.2": version: 5.8.2 resolution: "typescript@npm:5.8.2" bin: @@ -10542,7 +10777,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": +"typescript@patch:typescript@npm%3A^5.3.3#optional!builtin, typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": version: 5.8.2 resolution: "typescript@patch:typescript@npm%3A5.8.2#optional!builtin::version=5.8.2&hash=379a07" bin: @@ -10562,7 +10797,7 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~6.19.8": +"undici-types@npm:~6.19.2, undici-types@npm:~6.19.8": version: 6.19.8 resolution: "undici-types@npm:6.19.8" checksum: 10c0/078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 diff --git a/main.tf b/main.tf index b9456c0a52..779bc6eba9 100644 --- a/main.tf +++ b/main.tf @@ -353,6 +353,53 @@ module "ami_housekeeper" { lambda_schedule_expression = var.ami_housekeeper_lambda_schedule_expression } +module "ami_updater" { + count = var.enable_ami_updater ? 1 : 0 + source = "./modules/ami-updater" + + prefix = var.prefix + tags = local.tags + aws_partition = var.aws_partition + + lambda_zip = var.ami_updater_lambda_zip + lambda_memory_size = var.ami_updater_lambda_memory_size + lambda_timeout = var.ami_updater_lambda_timeout + lambda_s3_bucket = var.lambda_s3_bucket + lambda_runtime = var.lambda_runtime + lambda_architecture = var.lambda_architecture + + lambda_subnet_ids = var.lambda_subnet_ids + lambda_security_group_ids = var.lambda_security_group_ids + lambda_tags = var.lambda_tags + tracing_config = var.tracing_config + + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_level = var.log_level + + role_path = var.role_path + role_permissions_boundary = var.role_permissions_boundary + + ssm_parameter_name = var.ami_id_ssm_parameter_name + + config = { + dry_run = false + ami_filter = { + owners = var.ami_owners + filters = [ + { + name = "state" + values = ["available"] + }, + { + name = "image-type" + values = ["machine"] + } + ] + } + } +} + locals { lambda_instance_termination_watcher = { prefix = var.prefix diff --git a/modules/ami-updater/README.md b/modules/ami-updater/README.md new file mode 100644 index 0000000000..ad0a6e8b94 --- /dev/null +++ b/modules/ami-updater/README.md @@ -0,0 +1,103 @@ +# AMI Updater Module + +This module creates a Lambda function that automatically updates an SSM parameter with the latest AMI ID based on specified filters. The function runs on a schedule and can be configured to run in dry-run mode. + +## Features + +- Automatically finds the latest AMI based on configurable filters +- Updates SSM parameter with the latest AMI ID +- Supports dry-run mode for testing +- Configurable schedule via EventBridge +- Comprehensive logging and metrics using AWS Lambda Powertools +- Optional VPC configuration +- Optional X-Ray tracing + +## Usage + +```hcl +module "ami_updater" { + #source = "./modules/ami-updater" + source = "git::https://github.com/dgokcin/terraform-aws-github-runner.git//modules/ami-updater?ref=ami-updater-lambda" + + prefix = "test-${local.github_runner_prefix}-" + lambda_zip = "./gh-runner-${local.github_runner_version}-assets/ami-updater.zip" + ssm_parameter_name = "/github-action-runners/test-latest_ami_id" + + config = { + dry_run = false + ami_filter = { + owners = ["self"] + filters = [ + { + name = "name" + values = ["runs-on-v2.2-ubuntu24-full-x64-*"] + }, + { + name = "state" + values = ["available"] + } + ] + } + } + + # Optional configurations + schedule_expression = "rate(1 day)" + state = "ENABLED" + lambda_memory_size = 512 + lambda_timeout = 30 + log_level = "info" + + tags = { + Environment = "prod" + Project = "my-project" + } +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.0 | +| aws | >= 4.0 | + +## Providers + +| Name | Version | +|------|---------| +| aws | >= 4.0 | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| environment | The environment name for the resources. | `string` | n/a | yes | +| ssm_parameter_name | The name of the SSM parameter to store the latest AMI ID. | `string` | `/github-action-runners/latest_ami_id` | no | +| config | Configuration for the AMI updater. | `object` | n/a | yes | +| aws_partition | The AWS partition to use (e.g., aws, aws-cn) | `string` | `"aws"` | no | +| tags | Map of tags that will be added to created resources | `map(string)` | `{}` | no | +| lambda_runtime | AWS Lambda runtime | `string` | `"nodejs20.x"` | no | +| lambda_architecture | AWS Lambda architecture | `string` | `"x86_64"` | no | +| lambda_timeout | Time out of the lambda in seconds | `number` | `30` | no | +| lambda_memory_size | Lambda memory size limit | `number` | `512` | no | +| role_path | The path that will be added to the role | `string` | `null` | no | +| role_permissions_boundary | Permissions boundary for the role | `string` | `null` | no | +| lambda_subnet_ids | List of subnet IDs for the Lambda VPC config | `list(string)` | `null` | no | +| lambda_security_group_ids | List of security group IDs for the Lambda VPC config | `list(string)` | `null` | no | +| logging_retention_in_days | CloudWatch log retention in days | `number` | `180` | no | +| logging_kms_key_id | KMS key ID for CloudWatch log encryption | `string` | `null` | no | +| schedule_expression | EventBridge schedule expression | `string` | `"rate(1 day)"` | no | +| state | EventBridge rule state | `string` | `"ENABLED"` | no | +| log_level | Lambda function log level | `string` | `"info"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| lambda | The Lambda function details | +| role | The IAM role details | +| eventbridge | The EventBridge rule details | + +## License + +This module is licensed under the MIT License. See the LICENSE file for details. diff --git a/modules/ami-updater/data.tf b/modules/ami-updater/data.tf new file mode 100644 index 0000000000..902ba03530 --- /dev/null +++ b/modules/ami-updater/data.tf @@ -0,0 +1,25 @@ +data "aws_iam_policy_document" "lambda_assume_role_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "lambda_xray" { + count = var.tracing_config.mode != null ? 1 : 0 + + statement { + actions = [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets", + "xray:GetSamplingStatisticSummaries" + ] + resources = ["*"] + } +} diff --git a/modules/ami-updater/main.tf b/modules/ami-updater/main.tf new file mode 100644 index 0000000000..413442686f --- /dev/null +++ b/modules/ami-updater/main.tf @@ -0,0 +1,143 @@ +locals { + lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/ami-updater/ami-updater.zip" : var.lambda_zip + role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path + tags = merge( + { + Environment = var.prefix + Name = "${var.prefix}-ami-updater" + }, + var.tags + ) +} + +resource "aws_lambda_function" "ami_updater" { + s3_bucket = var.lambda_s3_bucket != null ? var.lambda_s3_bucket : null + s3_key = var.lambda_s3_key != null ? var.lambda_s3_key : null + s3_object_version = var.lambda_s3_object_version != null ? var.lambda_s3_object_version : null + filename = var.lambda_s3_bucket == null ? local.lambda_zip : null + source_code_hash = var.lambda_s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + function_name = "${var.prefix}-ami-updater" + role = aws_iam_role.ami_updater.arn + handler = "index.handler" + runtime = var.lambda_runtime + timeout = var.lambda_timeout + memory_size = var.lambda_memory_size + architectures = [var.lambda_architecture] + tags = merge(local.tags, var.lambda_tags) + + environment { + variables = { + LOG_LEVEL = var.log_level + DRY_RUN = tostring(var.config.dry_run) + POWERTOOLS_SERVICE_NAME = "ami-updater" + SSM_PARAMETER_NAME = var.ssm_parameter_name + AMI_FILTER = jsonencode(var.config.ami_filter) + } + } + + dynamic "vpc_config" { + for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : [] + content { + security_group_ids = var.lambda_security_group_ids + subnet_ids = var.lambda_subnet_ids + } + } + + dynamic "tracing_config" { + for_each = var.tracing_config.mode != null ? [true] : [] + content { + mode = var.tracing_config.mode + } + } +} + +resource "aws_cloudwatch_log_group" "ami_updater" { + name = "/aws/lambda/${aws_lambda_function.ami_updater.function_name}" + retention_in_days = var.logging_retention_in_days + kms_key_id = var.logging_kms_key_id + tags = var.tags +} + +resource "aws_cloudwatch_event_rule" "ami_updater" { + name = "${var.prefix}-ami-updater" + description = "Trigger AMI updater Lambda function" + schedule_expression = var.schedule_expression + state = var.state + tags = var.tags +} + +resource "aws_cloudwatch_event_target" "ami_updater" { + rule = aws_cloudwatch_event_rule.ami_updater.name + target_id = "TriggerAMIUpdaterLambda" + arn = aws_lambda_function.ami_updater.arn +} + +resource "aws_lambda_permission" "ami_updater" { + statement_id = "AllowExecutionFromCloudWatch" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.ami_updater.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.ami_updater.arn +} + +resource "aws_iam_role" "ami_updater" { + name = "${var.prefix}-ami-updater" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json + path = local.role_path + permissions_boundary = var.role_permissions_boundary + tags = local.tags +} + +resource "aws_iam_role_policy" "ami_updater" { + name = "ami-updater-policy" + role = aws_iam_role.ami_updater.name + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ec2:DescribeImages" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "ssm:PutParameter", + "ssm:GetParameter" + ] + Resource = "arn:${var.aws_partition}:ssm:*:*:parameter${var.ssm_parameter_name}" + } + ] + }) +} + +resource "aws_iam_role_policy" "ami_updater_logging" { + name = "logging-policy" + role = aws_iam_role.ami_updater.name + policy = templatefile("${path.module}/policies/lambda-cloudwatch.json", { + log_group_arn = aws_cloudwatch_log_group.ami_updater.arn + }) +} + +resource "aws_iam_role_policy_attachment" "ami_updater_vpc_execution_role" { + count = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? 1 : 0 + role = aws_iam_role.ami_updater.name + policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "ami_updater_xray" { + count = var.tracing_config.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.ami_updater.name +} + +resource "aws_ssm_parameter" "latest_ami_id" { + name = var.ssm_parameter_name + description = "Latest AMI ID for GitHub runners" + type = "String" + value = "placeholder" # Will be updated by Lambda function + tags = var.tags +} diff --git a/modules/ami-updater/outputs.tf b/modules/ami-updater/outputs.tf new file mode 100644 index 0000000000..3fe31a1653 --- /dev/null +++ b/modules/ami-updater/outputs.tf @@ -0,0 +1,23 @@ +output "lambda" { + description = "The Lambda function" + value = { + function_name = aws_lambda_function.ami_updater.function_name + arn = aws_lambda_function.ami_updater.arn + } +} + +output "role" { + description = "The IAM role of the Lambda function" + value = { + name = aws_iam_role.ami_updater.name + arn = aws_iam_role.ami_updater.arn + } +} + +output "eventbridge" { + description = "The EventBridge rule" + value = { + name = aws_cloudwatch_event_rule.ami_updater.name + arn = aws_cloudwatch_event_rule.ami_updater.arn + } +} diff --git a/modules/ami-updater/policies/lambda-cloudwatch.json b/modules/ami-updater/policies/lambda-cloudwatch.json new file mode 100644 index 0000000000..60cf5860f7 --- /dev/null +++ b/modules/ami-updater/policies/lambda-cloudwatch.json @@ -0,0 +1,13 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "${log_group_arn}:*" + } + ] +} \ No newline at end of file diff --git a/modules/ami-updater/variables.tf b/modules/ami-updater/variables.tf new file mode 100644 index 0000000000..3e3b1802e7 --- /dev/null +++ b/modules/ami-updater/variables.tf @@ -0,0 +1,162 @@ +variable "prefix" { + description = "The prefix used for naming resources" + type = string +} + +variable "aws_partition" { + description = "The AWS partition to use (e.g., aws, aws-cn)" + type = string + default = "aws" +} + +variable "tags" { + description = "Map of tags that will be added to created resources" + type = map(string) + default = {} +} + +variable "lambda_runtime" { + description = "AWS Lambda runtime" + type = string + default = "nodejs20.x" +} + +variable "lambda_architecture" { + description = "AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions." + type = string + default = "x86_64" + validation { + condition = contains(["arm64", "x86_64"], var.lambda_architecture) + error_message = "Valid values for lambda_architecture are (arm64, x86_64)." + } +} + +variable "lambda_timeout" { + description = "Time out of the lambda in seconds." + type = number + default = 30 +} + +variable "lambda_memory_size" { + description = "Lambda memory size limit." + type = number + default = 512 +} + +variable "role_path" { + description = "The path that will be added to the role, if not set the environment will be used." + type = string + default = null +} + +variable "role_permissions_boundary" { + description = "Permissions boundary that will be added to the created role." + type = string + default = null +} + +variable "lambda_subnet_ids" { + description = "List of subnets in which the lambda will be able to access." + type = list(string) + default = null +} + +variable "lambda_security_group_ids" { + description = "List of security group IDs associated with the Lambda function." + type = list(string) + default = null +} + +variable "logging_retention_in_days" { + description = "Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653." + type = number + default = 180 +} + +variable "logging_kms_key_id" { + description = "Specifies the kms key id to encrypt the logs with" + type = string + default = null +} + +variable "tracing_config" { + description = "Configuration for lambda tracing." + type = object({ + mode = optional(string, null) + capture_error = optional(bool, false) + capture_request = optional(bool, false) + }) + default = {} +} + +variable "log_level" { + description = "Logging level for lambda function." + type = string + default = "info" +} + +variable "schedule_expression" { + description = "The scheduling expression for triggering the Lambda function. For example, cron(0 20 * * ? *) or rate(5 minutes)." + type = string + default = "rate(1 day)" +} + +variable "state" { + description = "The state of the EventBridge rule. Valid values: ENABLED, DISABLED" + type = string + default = "ENABLED" + validation { + condition = contains(["ENABLED", "DISABLED"], var.state) + error_message = "Valid values for state are (ENABLED, DISABLED)." + } +} + +variable "ssm_parameter_name" { + description = "The name of the SSM parameter to store the latest AMI ID." + type = string + default = "/github-action-runners/latest_ami_id" +} + +variable "config" { + description = "Configuration for the AMI updater." + type = object({ + dry_run = optional(bool, true) + ami_filter = object({ + owners = list(string) + filters = list(object({ + name = string + values = list(string) + })) + }) + }) +} + +variable "lambda_zip" { + description = "Path to Lambda zip file, will be used when S3 bucket is not set" + type = string + default = null +} + +variable "lambda_s3_bucket" { + description = "S3 bucket from which to specify lambda functions source code" + type = string + default = null +} + +variable "lambda_s3_key" { + description = "S3 key from which to specify lambda function source code" + type = string + default = null +} + +variable "lambda_s3_object_version" { + description = "S3 object version from which to specify lambda function source code" + type = string + default = null +} + +variable "lambda_tags" { + description = "Additional tags to apply to the Lambda function" + type = map(string) + default = {} +} diff --git a/variables.tf b/variables.tf index cb30dcef5d..d469542918 100644 --- a/variables.tf +++ b/variables.tf @@ -33,9 +33,9 @@ variable "enable_organization_runners" { variable "github_app" { description = <