UNPKG

update-lambda-edge

Version:

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

414 lines (356 loc) 12 kB
const fs = require('fs') const AWS = require('aws-sdk') /** * Validates a configuration object * @param {CommandConfig} config A command configuration object * @param {string[]} requiredFields List of required config fields (excluding cfTriggers) * @param {string[]} requiredTriggerFields List of required config fields for each trigger * @returns {boolean} */ const validateConfig = (config, requiredFields = [], requiredTriggerFields = []) => { const validFields = [ 'dryRun', 'awsRegion', 's3Region', 'lambdaRegion', 'cfDistributionID', 'cacheBehaviorPath', 'autoIncrementVersion', 'lambdaCodeS3Bucket', 'lambdaCodeS3Bucket', 'cfTriggers', ] const validTriggerFields = [ 'cfTriggerName', 'lambdaFunctionName', 'lambdaFunctionVersion', 'lambdaCodeS3Key', 'lambdaCodeFilePath', ] // ensure all fields in the config are expected for (let field of Object.keys(config)) { if (!validFields.includes(field)) { console.log(`[VALIDATION ERROR]: unknown field '${field}' found in config.`, config) return false } } // ensure all required fields are defined for (let field of requiredFields) { if (typeof config[field] === 'undefined') { console.log(`[VALIDATION ERROR]: '${field}' is required for each trigger.`, config) return false } } // ensure that there is at least one trigger if (!config.cfTriggers?.length) { console.log(`[VALIDATION ERROR]: at least one trigger configuration is required.`, config) return false } // validate each trigger configuration for (let trigger of config.cfTriggers) { // ensure all fields in the trigger confg are expected for (let field of Object.keys(trigger)) { if (!validTriggerFields.includes(field)) { console.log(`[VALIDATION ERROR]: unknown field '${field}' found in trigger config.`, trigger) return false } } // ensure all required fields are defined for (let field of requiredTriggerFields) { if (typeof trigger[field] === 'undefined') { console.log(`[VALIDATION ERROR]: '${field}' is required for each trigger.`, trigger) return false } } } return true } /** * Iterates through all the versions of a Lambda function to find the most recent sequential version * * @param {Lambda.FunctionName} functionName The fully qualified Lambda function name * @param {AWS.Region} region The AWS region the Lambda is in * @param {string} version The sequential version number to get (gets the latest if blank) * @returns {Promise<Lambda.Version>} */ const getLambdaVersion = async (functionName, region, version = '') => { const lambda = new AWS.Lambda({ apiVersion: '2015-03-31', region, }) const versions = [] let nextMarker do { const versionData = await lambda .listVersionsByFunction({ FunctionName: functionName, Marker: nextMarker, }) .promise() nextMarker = versionData.NextMarker versions.push(...versionData.Versions) } while (nextMarker) // if no versions have been published, return an empty version if (!versions.length) { return { Version: '0', } } if (version) { return versions.find((v) => v.Version === version) } return versions .filter((version) => version.Version !== '$LATEST') .sort((a, b) => Number(b.Version) - Number(a.Version))[0] } /** * Fetches the full configuration for a CloudFront distribution * * @param {CloudFront.DistributionId} distributionID The CloudFront distribution ID * @returns {Promise<CloudFront.DistributionConfig>} */ const getCloudFrontDistributionConfig = async (distributionID) => { const cloudfront = new AWS.CloudFront({ apiVersion: '2020-05-31', }) return cloudfront .getDistributionConfig({ Id: distributionID, }) .promise() } /** * Modifies a CloudFront configuration with a new Lambda ARN for a specific trigger * * @param {CloudFront.DistributionConfig} distributionConfig The current CloudFront distribution config * @param {string} cacheBehaviorPath The PathPattern of the CacheBehavior to update, or "default" to use DefaultCacheBehavior * @param {Lambda.Arn} lambdaARN The ARN for the new Lambda to use as a trigger * @param {CloudFront.EventType} triggerName The name of the trigger event ['viewer-request'|'origin-request'|'origin-response'|'viewer-response'] * @returns {*} */ const changeCloudFrontDistributionLambdaARN = (distributionConfig, cacheBehaviorPath, lambdaARN, triggerName) => { try { const cacheBehavior = cacheBehaviorPath === 'default' ? distributionConfig.DistributionConfig.DefaultCacheBehavior : distributionConfig.DistributionConfig.CacheBehaviors.Items.find( (item) => item.PathPattern === cacheBehaviorPath, ) if (!cacheBehavior) { console.log('No cache behavior found for PathPattern', cacheBehaviorPath) return distributionConfig } const lambdaFunction = cacheBehavior.LambdaFunctionAssociations.Items.find((item) => item.EventType === triggerName) if (lambdaFunction.LambdaFunctionARN !== lambdaARN) { lambdaFunction.LambdaFunctionARN = lambdaARN } } catch (e) { // do nothing } return distributionConfig } /** * Updates a CloudFront distribution with the provided config * * @param {CloudFront.DistributionId} distributionID The CloudFront distribution ID * @param {CloudFront.DistributionConfig} distributionConfig The current CloudFront distribution config * @returns {Promise<CloudFront.UpdateDistributionResult, AWSError>} */ const updateCloudFrontDistribution = async (distributionID, distributionConfig) => { const cloudfront = new AWS.CloudFront({ apiVersion: '2020-05-31', }) return cloudfront .updateDistribution({ Id: distributionID, IfMatch: distributionConfig.ETag, DistributionConfig: distributionConfig.DistributionConfig, }) .promise() } /** * Pushes a ZIP file containing Lambda code to S3 * * @param config The project configuration to update * * @return {Promise<void>} */ const pushNewCodeBundles = async (config) => { if ( !validateConfig(config, ['lambdaCodeS3Bucket'], ['lambdaFunctionName', 'lambdaCodeS3Key', 'lambdaCodeFilePath']) ) { throw new Error('Invalid config.') } const s3 = new AWS.S3({ apiVersion: '2006-03-01', region: config.s3Region || config.awsRegion, }) for (let trigger of config.cfTriggers) { let version = trigger.lambdaFunctionVersion if (trigger.lambdaFunctionName && config.autoIncrementVersion) { version = `${ Number((await getLambdaVersion(trigger.lambdaFunctionName, config.lambdaRegion || config.awsRegion)).Version) + 1 }` } let key = trigger.lambdaCodeS3Key if (version) { key = key.replace(/\.zip$/, `-${version}.zip`) } const s3Config = { Bucket: config.lambdaCodeS3Bucket, Key: key, Body: fs.createReadStream(trigger.lambdaCodeFilePath), } console.log('Pushing to S3 with the following config:', { ...s3Config, Body: `File: ${trigger.lambdaCodeFilePath}`, }) if (config.dryRun) { console.log('[DRY RUN]: Not pushing code bundles to S3') continue } await s3.upload(s3Config).promise() console.log('Successfully pushed to S3.') } } /** * Updates a Lambda function's code with a ZIP file in S3 * * @param config The project configuration to update * * @return {Promise<void>} */ const deployLambdas = async (config) => { if (!validateConfig(config, ['lambdaCodeS3Bucket'], ['lambdaFunctionName', 'lambdaCodeS3Key'])) { throw new Error('Invalid config.') } const lambda = new AWS.Lambda({ apiVersion: '2015-03-31', region: config.lambdaRegion || config.awsRegion, }) const s3 = new AWS.S3({ apiVersion: '2006-03-01', region: config.s3Region || config.awsRegion, }) for (let trigger of config.cfTriggers) { let version = trigger.lambdaFunctionVersion if (config.autoIncrementVersion) { version = `${ Number((await getLambdaVersion(trigger.lambdaFunctionName, config.lambdaRegion || config.awsRegion)).Version) + 1 }` } let key = trigger.lambdaCodeS3Key if (version) { key = key.replace(/\.zip$/, `-${version}.zip`) } let lambdaConfig if (config.s3Region && config.lambdaRegion && config.s3Region !== config.lambdaRegion) { console.log("S3 and Lambda regions don't match. Downloading ZIP from S3...") lambdaConfig = { FunctionName: trigger.lambdaFunctionName, ZipFile: await s3 .getObject({ Bucket: config.s3BucketName, Key: key, }) .promise() .then((data) => data.Body), } } else { console.log('S3 and Lambda regions match.') lambdaConfig = { FunctionName: trigger.lambdaFunctionName, S3Bucket: config.lambdaCodeS3Bucket, S3Key: key, } } console.log('Updating Lambda code with the following config', lambdaConfig) if (config.dryRun) { console.log('[DRY RUN]: Not updating Lambda function code') continue } await lambda.updateFunctionCode(lambdaConfig).promise() console.log('Successfully deployed new code.') } } /** * Publishes a new version of a Lambda function * * @param config The project configuration to update * * @return {Promise<void>} */ const publishLambdas = async (config) => { if (!validateConfig(config, [], ['lambdaFunctionName'])) { throw new Error('Invalid config.') } const lambda = new AWS.Lambda({ apiVersion: '2015-03-31', region: config.lambdaRegion || config.awsRegion, }) for (let trigger of config.cfTriggers) { const lambdaConfig = { FunctionName: trigger.lambdaFunctionName, } console.log('Publishing new Lambda version with the following config:', lambdaConfig) if (config.dryRun) { console.log('[DRY RUN]: Not publishing new Lambda versions') continue } await lambda.publishVersion(lambdaConfig).promise() console.log('Successfully published new Lambda version.') } } /** * Sets the Lambda@Edge triggers for the specified Lambda functions on the specified CloudFront distribution * * @param config The project configuration to update * * @return {Promise<void>} */ const activateLambdas = async (config) => { if (!validateConfig(config, ['cfDistributionID', 'cacheBehaviorPath'], ['lambdaFunctionName'])) { throw new Error('Invalid config.') } // first, get the CF distro const distroConfig = await getCloudFrontDistributionConfig(config.cfDistributionID) const lambdaARNs = {} await Promise.all( config.cfTriggers.map(async (trigger) => { lambdaARNs[trigger.cfTriggerName] = ( await getLambdaVersion( trigger.lambdaFunctionName, config.lambdaRegion || config.awsRegion, config.autoIncrementVersion ? '' : trigger.lambdaFunctionVersion, ) ).FunctionArn }), ) console.log('Activating the following ARNs:', lambdaARNs) const cacheBehaviorPath = config.cacheBehaviorPath // then, set the arns in the config (filter out missing arns) const updatedConfig = Object.entries(lambdaARNs) .filter(([, arn]) => !!arn) .reduce( (config, [triggerName, arn]) => changeCloudFrontDistributionLambdaARN(config, cacheBehaviorPath, arn, triggerName), distroConfig, ) // do not update if this is a dry run if (config.dryRun) { console.log('[DRY RUN]: Not updating CloudFront distribution triggers') return } // finally, update the distro await updateCloudFrontDistribution(config.cfDistributionID, updatedConfig) console.log('Successfully activated new Lambdas.') } module.exports = { pushNewCodeBundles, deployLambdas, publishLambdas, activateLambdas, validateConfig, }