UNPKG

update-lambda-edge

Version:

Scripts for updating CloudFront distributions with new Lambda@Edge function versions

771 lines (658 loc) 23 kB
const fs = require('fs') const AWS = require('aws-sdk') const { validateConfig, pushNewCodeBundles, deployLambdas, publishLambdas, activateLambdas } = require('./index') jest.mock('fs') jest.mock('aws-sdk') describe('update lambda edge API', () => { let s3ConstructorMock let lambdaConstructorMock let s3UploadMock let s3GetObjectMock let lambdaListVersionsByFunctionMock let lambdaUpdateFunctionCodeMock let lambdaPublishVersionMock let cloudFrontGetDistributionConfigMock let cloudFrontUpdateDistributionMock let fakeConfig beforeEach(() => { // silence console output jest.spyOn(console, 'log').mockImplementation(() => {}) s3ConstructorMock = jest.fn() lambdaConstructorMock = jest.fn() s3UploadMock = jest.fn() s3GetObjectMock = jest.fn() lambdaListVersionsByFunctionMock = jest.fn() lambdaUpdateFunctionCodeMock = jest.fn() lambdaPublishVersionMock = jest.fn() cloudFrontGetDistributionConfigMock = jest.fn() cloudFrontUpdateDistributionMock = jest.fn() AWS.S3 = class S3 { constructor({ apiVersion, region }) { this.apiVersion = apiVersion this.region = region s3ConstructorMock({ apiVersion, region }) } upload(...args) { return { promise: () => s3UploadMock(...args), } } getObject(...args) { return { promise: () => s3GetObjectMock(...args), } } } AWS.Lambda = class Lambda { constructor({ apiVersion, region }) { this.apiVersion = apiVersion this.region = region lambdaConstructorMock({ apiVersion, region }) } listVersionsByFunction(...args) { return { promise: () => lambdaListVersionsByFunctionMock(...args), } } updateFunctionCode(...args) { return { promise: () => lambdaUpdateFunctionCodeMock(...args), } } publishVersion(...args) { return { promise: () => lambdaPublishVersionMock(...args), } } } AWS.CloudFront = class CloudFront { constructor({ apiVersion, region }) { this.apiVersion = apiVersion this.region = region } getDistributionConfig(...args) { return { promise: () => cloudFrontGetDistributionConfigMock(...args), } } updateDistribution(...args) { return { promise: () => cloudFrontUpdateDistributionMock(...args), } } } fakeConfig = { awsRegion: undefined, cfDistributionID: 'cloudfront', cacheBehaviorPath: 'default', autoIncrementVersion: true, lambdaCodeS3Bucket: 'bucket', cfTriggers: [ { cfTriggerName: 'viewer-request', lambdaFunctionName: 'viewer-request-fake', lambdaCodeS3Key: 'root/fake/path/to/vreq-lambda.zip', lambdaCodeFilePath: '/absolute/path/to/vreq-lambda-local.zip', }, { cfTriggerName: 'origin-request', lambdaFunctionName: 'origin-request-fake', lambdaCodeS3Key: 'root/fake/path/to/oreq-lambda.zip', lambdaCodeFilePath: 'relative/path/to/oreq-lambda-local.zip', }, { cfTriggerName: 'origin-response', lambdaFunctionName: 'origin-response-fake', lambdaCodeS3Key: 'root/fake/path/to/ores-lambda.zip', lambdaCodeFilePath: '/dist/ores-lambda-local.zip', }, { cfTriggerName: 'viewer-response', lambdaFunctionName: 'viewer-response-fake', lambdaCodeS3Key: 'root/fake/path/to/vres-lambda.zip', lambdaCodeFilePath: '/dist/vres-lambda-local.zip', }, ], } }) describe('validateConfig()', () => { it('should ensure that all required fields are present', () => { const validConfig = validateConfig(fakeConfig, ['cfDistributionID', 'lambdaCodeS3Bucket'], ['cfTriggerName']) const invalidConfig = validateConfig(fakeConfig, ['awsRegion', 'lambdaCodeS3Bucket'], ['cfTriggerName']) const invalidTrigger = validateConfig( fakeConfig, ['lambdaCodeS3Bucket'], ['cfTriggerName', 'lambdaFunctionVersion'], ) expect(validConfig).toBe(true) expect(invalidConfig).toBe(false) expect(invalidTrigger).toBe(false) }) it('should ensure that all config fields are known', () => { fakeConfig.notARealField = 'hello' expect(validateConfig(fakeConfig)).toBe(false) }) it('should ensure that all trigger fields are known', () => { fakeConfig.cfTriggers[0].notARealField = 'hello' expect(validateConfig(fakeConfig)).toBe(false) }) it('should ensure at least one trigger', () => { fakeConfig.cfTriggers = [] expect(validateConfig(fakeConfig)).toBe(false) }) }) describe('pushNewCodeBundles()', () => { beforeEach(() => { // just return the path for debugging fs.createReadStream.mockImplementation((path) => path) // return the latest version as "1" lambdaListVersionsByFunctionMock.mockImplementation(() => ({ Versions: [ { Version: '1', }, ], })) }) it('should validate the config', async () => { fakeConfig.lambdaCodeS3Bucket = undefined return expect(pushNewCodeBundles(fakeConfig)).rejects.toThrow('Invalid config.') }) it('should use the awsRegion', async () => { fakeConfig.awsRegion = 'fake-region' await pushNewCodeBundles(fakeConfig) expect(s3ConstructorMock).toHaveBeenCalledWith({ apiVersion: '2006-03-01', region: 'fake-region', }) expect(lambdaConstructorMock).toHaveBeenCalledWith({ apiVersion: '2015-03-31', region: 'fake-region', }) }) it('should use s3Region and lambdaRegion', async () => { fakeConfig.awsRegion = 'fake-region' fakeConfig.s3Region = 'fake-s3-region' fakeConfig.lambdaRegion = 'fake-lambda-region' await pushNewCodeBundles(fakeConfig) expect(s3ConstructorMock).toHaveBeenCalledWith({ apiVersion: '2006-03-01', region: 'fake-s3-region', }) expect(lambdaConstructorMock).toHaveBeenCalledWith({ apiVersion: '2015-03-31', region: 'fake-lambda-region', }) }) it('should auto-increment the version', async () => { await pushNewCodeBundles(fakeConfig) expect(s3UploadMock).toHaveBeenCalledTimes(4) expect(s3UploadMock).toHaveBeenCalledWith({ Bucket: 'bucket', Key: 'root/fake/path/to/vres-lambda-2.zip', Body: '/dist/vres-lambda-local.zip', }) }) it('should use version 1 if auto-increment is true and there are no published versions', async () => { lambdaListVersionsByFunctionMock.mockImplementation(() => ({ Versions: [], })) await pushNewCodeBundles(fakeConfig) expect(s3UploadMock).toHaveBeenCalledTimes(4) expect(s3UploadMock).toHaveBeenCalledWith({ Bucket: 'bucket', Key: 'root/fake/path/to/vres-lambda-1.zip', Body: '/dist/vres-lambda-local.zip', }) }) it('should use the provided version', async () => { fakeConfig.autoIncrementVersion = false fakeConfig.cfTriggers.forEach((t) => (t.lambdaFunctionVersion = '5')) await pushNewCodeBundles(fakeConfig) expect(s3UploadMock).toHaveBeenCalledTimes(4) expect(s3UploadMock).toHaveBeenCalledWith({ Bucket: 'bucket', Key: 'root/fake/path/to/vres-lambda-5.zip', Body: '/dist/vres-lambda-local.zip', }) }) it('should use no version', async () => { fakeConfig.autoIncrementVersion = false await pushNewCodeBundles(fakeConfig) expect(s3UploadMock).toHaveBeenCalledTimes(4) expect(s3UploadMock).toHaveBeenCalledWith({ Bucket: 'bucket', Key: 'root/fake/path/to/vres-lambda.zip', Body: '/dist/vres-lambda-local.zip', }) }) it("should not upload when it's a dry run", async () => { fakeConfig.dryRun = true await pushNewCodeBundles(fakeConfig) expect(s3UploadMock).toHaveBeenCalledTimes(0) }) }) describe('deployLambdas()', () => { beforeEach(() => { // return the latest version as "1" lambdaListVersionsByFunctionMock.mockImplementation(() => ({ Versions: [ { Version: '1', }, ], })) }) it('should validate the config', async () => { fakeConfig.lambdaCodeS3Bucket = undefined return expect(deployLambdas(fakeConfig)).rejects.toThrow('Invalid config.') }) it('should use the awsRegion', async () => { fakeConfig.awsRegion = 'fake-region' await deployLambdas(fakeConfig) expect(s3ConstructorMock).toHaveBeenCalledWith({ apiVersion: '2006-03-01', region: 'fake-region', }) expect(lambdaConstructorMock).toHaveBeenCalledWith({ apiVersion: '2015-03-31', region: 'fake-region', }) }) it('should use s3Region and lambdaRegion', async () => { fakeConfig.awsRegion = 'fake-region' fakeConfig.s3Region = 'fake-s3-region' fakeConfig.lambdaRegion = 'fake-lambda-region' s3GetObjectMock.mockResolvedValue({ Body: 'zip file buffer', }) await deployLambdas(fakeConfig) expect(s3ConstructorMock).toHaveBeenCalledWith({ apiVersion: '2006-03-01', region: 'fake-s3-region', }) expect(lambdaConstructorMock).toHaveBeenCalledWith({ apiVersion: '2015-03-31', region: 'fake-lambda-region', }) }) it('should auto-increment the version', async () => { await deployLambdas(fakeConfig) expect(lambdaUpdateFunctionCodeMock).toHaveBeenCalledTimes(4) expect(lambdaUpdateFunctionCodeMock).toHaveBeenCalledWith({ FunctionName: 'viewer-response-fake', S3Bucket: 'bucket', S3Key: 'root/fake/path/to/vres-lambda-2.zip', }) }) it('should use the provided version', async () => { fakeConfig.autoIncrementVersion = false fakeConfig.cfTriggers.forEach((t) => (t.lambdaFunctionVersion = '5')) await deployLambdas(fakeConfig) expect(lambdaUpdateFunctionCodeMock).toHaveBeenCalledTimes(4) expect(lambdaUpdateFunctionCodeMock).toHaveBeenCalledWith({ FunctionName: 'viewer-response-fake', S3Bucket: 'bucket', S3Key: 'root/fake/path/to/vres-lambda-5.zip', }) }) it('should use no version', async () => { fakeConfig.autoIncrementVersion = false await deployLambdas(fakeConfig) expect(lambdaUpdateFunctionCodeMock).toHaveBeenCalledTimes(4) expect(lambdaUpdateFunctionCodeMock).toHaveBeenCalledWith({ FunctionName: 'viewer-response-fake', S3Bucket: 'bucket', S3Key: 'root/fake/path/to/vres-lambda.zip', }) }) it('should download from S3 when s3Region and lambdaRegion are set', async () => { fakeConfig.s3Region = 'fake-s3-region' fakeConfig.lambdaRegion = 'fake-lambda-region' s3GetObjectMock.mockResolvedValue({ Body: 'zip file buffer', }) await deployLambdas(fakeConfig) expect(lambdaUpdateFunctionCodeMock).toHaveBeenCalledTimes(4) expect(lambdaUpdateFunctionCodeMock).toHaveBeenCalledWith({ FunctionName: 'viewer-response-fake', ZipFile: 'zip file buffer', }) }) it("should not deploy when it's a dry run", async () => { fakeConfig.dryRun = true await deployLambdas(fakeConfig) expect(lambdaUpdateFunctionCodeMock).toHaveBeenCalledTimes(0) }) }) describe('publishLambdas()', () => { it('should validate the config', async () => { fakeConfig.cfTriggers[0].lambdaFunctionName = undefined return expect(publishLambdas(fakeConfig)).rejects.toThrow('Invalid config.') }) it('should use the awsRegion', async () => { fakeConfig.awsRegion = 'fake-region' await publishLambdas(fakeConfig) expect(lambdaConstructorMock).toHaveBeenCalledWith({ apiVersion: '2015-03-31', region: 'fake-region', }) }) it('should use the lambdaRegion', async () => { fakeConfig.awsRegion = 'fake-region' fakeConfig.lambdaRegion = 'fake-lambda-region' await publishLambdas(fakeConfig) expect(lambdaConstructorMock).toHaveBeenCalledWith({ apiVersion: '2015-03-31', region: 'fake-lambda-region', }) }) it('should publish a new version of the lambdas', async () => { await publishLambdas(fakeConfig) expect(lambdaPublishVersionMock).toHaveBeenCalledTimes(4) expect(lambdaPublishVersionMock).toHaveBeenCalledWith({ FunctionName: 'viewer-response-fake', }) }) it("should not publish a new version of the lambdas if it's a dry run", async () => { fakeConfig.dryRun = true await publishLambdas(fakeConfig) expect(lambdaPublishVersionMock).toHaveBeenCalledTimes(0) }) }) describe('activateLambdas()', () => { let cloudFrontDistributionConfig beforeEach(() => { cloudFrontDistributionConfig = { ETag: 'etag', DistributionConfig: { DefaultCacheBehavior: { LambdaFunctionAssociations: { Items: [ { EventType: 'viewer-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-response', LambdaFunctionARN: 'old-arn', }, { EventType: 'viewer-response', LambdaFunctionARN: 'old-arn', }, ], }, }, CacheBehaviors: { Items: [ { PathPattern: '/*', LambdaFunctionAssociations: { Items: [ { EventType: 'viewer-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-response', LambdaFunctionARN: 'old-arn', }, { EventType: 'viewer-response', LambdaFunctionARN: 'old-arn', }, ], }, }, ], }, }, } cloudFrontGetDistributionConfigMock.mockResolvedValue(cloudFrontDistributionConfig) lambdaListVersionsByFunctionMock.mockImplementation(() => ({ Versions: [ { Version: '1', FunctionArn: 'arn-v1', }, { Version: '2', FunctionArn: 'arn-v2', }, { Version: '3', FunctionArn: 'arn-v3', }, ], })) }) it('should validate the config', async () => { fakeConfig.cfDistributionID = undefined return expect(activateLambdas(fakeConfig)).rejects.toThrow('Invalid config.') }) it('should use the awsRegion', async () => { fakeConfig.awsRegion = 'fake-region' await activateLambdas(fakeConfig) expect(lambdaConstructorMock).toHaveBeenCalledWith({ apiVersion: '2015-03-31', region: 'fake-region', }) }) it('should use the lambdaRegion', async () => { fakeConfig.awsRegion = 'fake-region' fakeConfig.lambdaRegion = 'fake-lambda-region' await activateLambdas(fakeConfig) expect(lambdaConstructorMock).toHaveBeenCalledWith({ apiVersion: '2015-03-31', region: 'fake-lambda-region', }) }) it('should update the default cache behavior with the latest ARNs', async () => { await activateLambdas(fakeConfig) expect(cloudFrontUpdateDistributionMock).toHaveBeenCalledTimes(1) expect(cloudFrontUpdateDistributionMock).toHaveBeenCalledWith({ Id: 'cloudfront', IfMatch: 'etag', DistributionConfig: { DefaultCacheBehavior: { LambdaFunctionAssociations: { Items: [ { EventType: 'viewer-request', LambdaFunctionARN: 'arn-v3', }, { EventType: 'origin-request', LambdaFunctionARN: 'arn-v3', }, { EventType: 'origin-response', LambdaFunctionARN: 'arn-v3', }, { EventType: 'viewer-response', LambdaFunctionARN: 'arn-v3', }, ], }, }, CacheBehaviors: { Items: [ { PathPattern: '/*', LambdaFunctionAssociations: { Items: [ { EventType: 'viewer-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-response', LambdaFunctionARN: 'old-arn', }, { EventType: 'viewer-response', LambdaFunctionARN: 'old-arn', }, ], }, }, ], }, }, }) }) it('should update the specified cache behavior with the latest ARNs', async () => { fakeConfig.cacheBehaviorPath = '/*' await activateLambdas(fakeConfig) expect(cloudFrontUpdateDistributionMock).toHaveBeenCalledTimes(1) expect(cloudFrontUpdateDistributionMock).toHaveBeenCalledWith({ Id: 'cloudfront', IfMatch: 'etag', DistributionConfig: { DefaultCacheBehavior: { LambdaFunctionAssociations: { Items: [ { EventType: 'viewer-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-response', LambdaFunctionARN: 'old-arn', }, { EventType: 'viewer-response', LambdaFunctionARN: 'old-arn', }, ], }, }, CacheBehaviors: { Items: [ { PathPattern: '/*', LambdaFunctionAssociations: { Items: [ { EventType: 'viewer-request', LambdaFunctionARN: 'arn-v3', }, { EventType: 'origin-request', LambdaFunctionARN: 'arn-v3', }, { EventType: 'origin-response', LambdaFunctionARN: 'arn-v3', }, { EventType: 'viewer-response', LambdaFunctionARN: 'arn-v3', }, ], }, }, ], }, }, }) }) it('should update the default cache behavior with specific version ARNs', async () => { fakeConfig.cfTriggers[0].lambdaFunctionVersion = '1' fakeConfig.cfTriggers[1].lambdaFunctionVersion = '2' fakeConfig.cfTriggers[2].lambdaFunctionVersion = '3' fakeConfig.cfTriggers[3].lambdaFunctionVersion = undefined // will get latest fakeConfig.autoIncrementVersion = false await activateLambdas(fakeConfig) expect(cloudFrontUpdateDistributionMock).toHaveBeenCalledTimes(1) expect(cloudFrontUpdateDistributionMock).toHaveBeenCalledWith({ Id: 'cloudfront', IfMatch: 'etag', DistributionConfig: { DefaultCacheBehavior: { LambdaFunctionAssociations: { Items: [ { EventType: 'viewer-request', LambdaFunctionARN: 'arn-v1', }, { EventType: 'origin-request', LambdaFunctionARN: 'arn-v2', }, { EventType: 'origin-response', LambdaFunctionARN: 'arn-v3', }, { EventType: 'viewer-response', LambdaFunctionARN: 'arn-v3', }, ], }, }, CacheBehaviors: { Items: [ { PathPattern: '/*', LambdaFunctionAssociations: { Items: [ { EventType: 'viewer-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-request', LambdaFunctionARN: 'old-arn', }, { EventType: 'origin-response', LambdaFunctionARN: 'old-arn', }, { EventType: 'viewer-response', LambdaFunctionARN: 'old-arn', }, ], }, }, ], }, }, }) }) it('should not update anything if invalid cache behavior path is supplied', async () => { fakeConfig.cacheBehaviorPath = '/invalid/path/pattern/*' await activateLambdas(fakeConfig) expect(cloudFrontUpdateDistributionMock).toHaveBeenCalledTimes(1) expect(cloudFrontUpdateDistributionMock).toHaveBeenCalledWith({ Id: 'cloudfront', IfMatch: 'etag', DistributionConfig: cloudFrontDistributionConfig.DistributionConfig, }) }) it("should not update the distribution config if it's a dry run", async () => { fakeConfig.dryRun = true await activateLambdas(fakeConfig) expect(cloudFrontUpdateDistributionMock).toHaveBeenCalledTimes(0) }) }) })