UNPKG

serverless

Version:

Serverless Framework - Build web, mobile and IoT applications with serverless architectures using AWS Lambda, Azure Functions, Google CloudFunctions & more

673 lines (617 loc) • 24.7 kB
'use strict'; const _ = require('lodash'); const url = require('url'); const chalk = require('chalk'); const { ServerlessError } = require('../../../../../classes/Error'); const originLimits = { maxTimeout: 30, maxMemorySize: 10240 }; const viewerLimits = { maxTimeout: 5, maxMemorySize: 128 }; class AwsCompileCloudFrontEvents { constructor(serverless, options) { this.serverless = serverless; this.options = options; this.provider = this.serverless.getProvider('aws'); this.lambdaEdgeLimits = { 'origin-request': originLimits, 'origin-response': originLimits, 'viewer-request': viewerLimits, 'viewer-response': viewerLimits, 'default': viewerLimits, }; this.cachePolicies = new Set(); const originObjectSchema = { type: 'object', properties: { ConnectionAttempts: { type: 'integer', miminum: 1, maximum: 3 }, ConnectionTimeout: { type: 'integer', miminum: 1, maximum: 10 }, CustomOriginConfig: { type: 'object', properties: { HTTPPort: { type: 'integer', miminum: 0, maximum: 65535 }, HTTPSPort: { type: 'integer', miminum: 0, maximum: 65535 }, OriginKeepaliveTimeout: { type: 'integer', miminum: 1, maximum: 60 }, OriginProtocolPolicy: { enum: ['http-only', 'match-viewer', 'https-only'], }, OriginReadTimeout: { type: 'integer', miminum: 1, maximum: 60 }, OriginSSLProtocols: { type: 'array', items: { enum: ['SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2'] }, }, }, additionalProperties: false, required: ['OriginProtocolPolicy'], }, DomainName: { type: 'string' }, OriginCustomHeaders: { type: 'array', items: { type: 'object', properties: { HeaderName: { type: 'string' }, HeaderValue: { type: 'string' }, }, additionalProperties: false, required: ['HeaderName', 'HeaderValue'], }, }, OriginPath: { type: 'string' }, S3OriginConfig: { type: 'object', properties: { OriginAccessIdentity: { type: 'string', pattern: '^origin-access-identity/cloudfront/.+', }, }, additionalProperties: false, }, }, additionalProperties: false, required: ['DomainName', 'Id'], oneOf: [{ required: ['CustomOriginConfig'] }, { required: ['S3OriginConfig'] }], }; const behaviorObjectSchema = { type: 'object', properties: { AllowedMethods: { anyOf: [ { type: 'array', uniqueItems: true, minItems: 2, items: { enum: ['GET', 'HEAD'] }, }, { type: 'array', uniqueItems: true, minItems: 3, items: { enum: ['GET', 'HEAD', 'OPTIONS'] }, }, { type: 'array', uniqueItems: true, minItems: 7, items: { enum: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'POST', 'DELETE'] }, }, ], }, CachedMethods: { anyOf: [ { type: 'array', uniqueItems: true, minItems: 2, items: { enum: ['GET', 'HEAD'] }, }, { type: 'array', uniqueItems: true, minItems: 3, items: { enum: ['GET', 'HEAD', 'OPTIONS'] }, }, ], }, ForwardedValues: { type: 'object', properties: { Cookies: { anyOf: [ { type: 'object', properties: { Forward: { enum: ['all', 'none'] }, }, additionalProperties: false, required: ['Forward'], }, { type: 'object', properties: { Forward: { const: 'whitelist' }, WhitelistedNames: { type: 'array', items: { type: 'string' } }, }, additionalProperties: false, required: ['Forward', 'WhitelistedNames'], }, ], }, Headers: { type: 'array', items: { type: 'string' } }, QueryString: { type: 'boolean' }, QueryStringCacheKeys: { type: 'array', items: { type: 'string' } }, }, additionalProperties: false, required: ['QueryString'], }, CachePolicyId: { type: 'string' }, Compress: { type: 'boolean' }, FieldLevelEncryptionId: { type: 'string' }, OriginRequestPolicyId: { type: 'string' }, SmoothStreaming: { type: 'boolean' }, TrustedSigners: { type: 'array', items: { type: 'string' } }, ViewerProtocolPolicy: { enum: ['allow-all', 'redirect-to-https', 'https-only'], }, }, additionalProperties: false, }; this.serverless.configSchemaHandler.defineFunctionEvent('aws', 'cloudFront', { type: 'object', properties: { behavior: behaviorObjectSchema, cachePolicy: { type: 'object', properties: { id: { $ref: '#/definitions/awsCfInstruction' }, name: { type: 'string', minLength: 1 }, }, oneOf: [{ required: ['id'] }, { required: ['name'] }], additionalProperties: false, }, eventType: { enum: ['viewer-request', 'origin-request', 'origin-response', 'viewer-response'], }, isDefaultOrigin: { type: 'boolean' }, includeBody: { type: 'boolean' }, origin: { anyOf: [{ type: 'string', format: 'uri' }, originObjectSchema], }, pathPattern: { type: 'string', pattern: '^([A-Za-z0-9_.*$/~"\'@:+-]|&)+$' }, }, additionalProperties: false, }); this.hooks = { 'intialize': () => { if ( this.serverless.service.provider.name === 'aws' && Object.values(this.serverless.service.functions).some(({ events }) => events.some(({ cloudFront: eventObject }) => { const behaviorConfig = eventObject && eventObject.behavior; if (!behaviorConfig) return false; return ( behaviorConfig.ForwardedValues || behaviorConfig.MinTTL !== null || behaviorConfig.MaxTTL !== null || behaviorConfig.DefaultTTL !== null ); }) ) ) { this.serverless._logDeprecation( 'CLOUDFRONT_CACHE_BEHAVIOR_FORWARDED_VALUES_AND_TTL', 'Cloudfront has deprecated the use of the ForwardedValues, MinTTL, MaxTTL' + 'and DefaultTTL field to configure cache behavior.' + 'Please use "provider.cloudfront.cachePolicies" to define Cache Policies' + 'and reference it here with "cachePolicy.name" property.' + 'You can also reference existing policies with "cachePolicy.id".' ); } }, 'package:initialize': this.validate.bind(this), 'before:package:compileFunctions': this.prepareFunctions.bind(this), 'package:compileEvents': () => { this.compileCloudFrontCachePolicies(); this.compileCloudFrontEvents(); }, 'before:remove:remove': this.logRemoveReminder.bind(this), }; } logRemoveReminder() { if (this.serverless.processedInput.commands[0] === 'remove') { let isEventUsed = false; const funcKeys = this.serverless.service.getAllFunctions(); if (funcKeys.length) { isEventUsed = funcKeys.some((funcKey) => { const func = this.serverless.service.getFunction(funcKey); return func.events && func.events.find((e) => Object.keys(e)[0] === 'cloudFront'); }); } if (isEventUsed) { const message = [ "Don't forget to manually remove your Lambda@Edge functions ", 'once the CloudFront distribution removal is successfully propagated!', ].join(''); this.serverless.cli.log(message, 'Serverless', { color: 'orange' }); } } } validate() { this.serverless.service.getAllFunctions().forEach((functionName) => { const functionObj = this.serverless.service.getFunction(functionName); functionObj.events.forEach(({ cloudFront }) => { if (!cloudFront) return; const { eventType = 'default' } = cloudFront; const { maxMemorySize, maxTimeout } = this.lambdaEdgeLimits[eventType]; if (functionObj.memorySize && functionObj.memorySize > maxMemorySize) { throw new Error( `"${functionName}" memorySize is greater than ${maxMemorySize} which is not supported by Lambda@Edge functions of type "${eventType}"` ); } if (functionObj.timeout && functionObj.timeout > maxTimeout) { throw new Error( `"${functionName}" timeout is greater than ${maxTimeout} which is not supported by Lambda@Edge functions of type "${eventType}"` ); } }); }); } prepareFunctions() { // Lambda@Edge functions need to be versioned this.serverless.service.getAllFunctions().forEach((functionName) => { const functionObj = this.serverless.service.getFunction(functionName); if (functionObj.events.find((event) => event.cloudFront)) { // ensure that functions are versioned Object.assign(functionObj, { versionFunction: true }); // set the maximum memory size if not explicitly configured if (!functionObj.memorySize) { Object.assign(functionObj, { memorySize: 128 }); } // set the maximum timeout if not explicitly configured if (!functionObj.timeout) { Object.assign(functionObj, { timeout: 5 }); } } }); } compileCloudFrontCachePolicies() { const userConfig = this.serverless.service.provider.cloudFront || {}; if (userConfig.cachePolicies) { const Resources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources; for (const [name, cachePolicyConfig] of Object.entries(userConfig.cachePolicies)) { this.cachePolicies.add(name); Object.assign(Resources, { [this.provider.naming.getCloudFrontCachePolicyLogicalId(name)]: { Type: 'AWS::CloudFront::CachePolicy', Properties: { CachePolicyConfig: { ...cachePolicyConfig, Name: name, }, }, }, }); } } } compileCloudFrontEvents() { this.cloudFrontDistributionLogicalId = this.provider.naming.getCloudFrontDistributionLogicalId(); this.cloudFrontDistributionDomainNameLogicalId = this.provider.naming.getCloudFrontDistributionDomainNameLogicalId(); const lambdaAtEdgeFunctions = []; const origins = []; const behaviors = []; let defaultOrigin; const Resources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources; const Outputs = this.serverless.service.provider.compiledCloudFormationTemplate.Outputs; // helper function for joining origins and behaviors function extendDeep(object, source) { return _.assignWith(object, source, (a, b) => { if (Array.isArray(a)) { return _.uniqWith(a.concat(b), _.isEqual); } if (_.isObject(a)) { extendDeep(a, b); } return a; }); } function createOrigin(origin, naming) { const originObj = {}; if (typeof origin === 'string') { const originUrl = url.parse(origin); Object.assign(originObj, { DomainName: originUrl.hostname, }); if (originUrl.pathname && originUrl.pathname.length > 1) { Object.assign(originObj, { OriginPath: originUrl.pathname }); } if (originUrl.protocol === 's3:') { Object.assign(originObj, { S3OriginConfig: {} }); } else { Object.assign(originObj, { CustomOriginConfig: { OriginProtocolPolicy: 'match-viewer', }, }); } } else { Object.assign(originObj, origin); } Object.assign(originObj, { Id: naming.getCloudFrontOriginId(originObj), }); return originObj; } const unusedUserDefinedCachePolicies = new Set(this.cachePolicies); this.serverless.service.getAllFunctions().forEach((functionName) => { const functionObj = this.serverless.service.getFunction(functionName); if (functionObj.events) { functionObj.events.forEach((event) => { if (event.cloudFront) { const lambdaFunctionLogicalId = Object.keys(Resources).find( (key) => Resources[key].Type === 'AWS::Lambda::Function' && Resources[key].Properties.FunctionName === functionObj.name ); // Remove VPC & Env vars from lambda@Edge delete Resources[lambdaFunctionLogicalId].Properties.VpcConfig; delete Resources[lambdaFunctionLogicalId].Properties.Environment; // Retain Lambda@Edge functions to avoid issues when removing the CloudFormation stack Object.assign(Resources[lambdaFunctionLogicalId], { DeletionPolicy: 'Retain' }); const lambdaVersionLogicalId = Object.keys(Resources).find((key) => { const resource = Resources[key]; if (resource.Type !== 'AWS::Lambda::Version') return false; return _.get(resource, 'Properties.FunctionName.Ref') === lambdaFunctionLogicalId; }); const pathPattern = typeof event.cloudFront.pathPattern === 'string' ? event.cloudFront.pathPattern : undefined; let origin = createOrigin(event.cloudFront.origin, this.provider.naming); const existingOrigin = origins.find((o) => o.Id === origin.Id); if (!existingOrigin) { origins.push(origin); } else { origin = extendDeep(existingOrigin, origin); } if (event.cloudFront.isDefaultOrigin) { if (defaultOrigin && defaultOrigin !== origin) { throw new ServerlessError( 'Found more than one cloudfront event with "isDefaultOrigin" defined' ); } defaultOrigin = origin; } let behavior = { ViewerProtocolPolicy: 'allow-all', }; let shouldAssignCachePolicy = true; if (event.cloudFront.behavior) { if ( event.cloudFront.behavior.ForwardedValues || event.cloudFront.behavior.MinTTL !== null || event.cloudFront.behavior.MaxTTL !== null || event.cloudFront.behavior.DefaultTTL !== null ) { behavior.ForwardedValues = { QueryString: false }; shouldAssignCachePolicy = false; } Object.assign(behavior, event.cloudFront.behavior); } if (event.cloudFront.cachePolicy) { if (!shouldAssignCachePolicy) { throw new this.serverless.classes.Error( 'Both cachePolicy and deprecated fields ForwardedValues, MinTTL, MaxTTL' + 'and DefaultTTL found in function ${functionObj.name} configuration.' + 'Specifying a cachePolicy override those deprecated parameters.' + 'Please remove one of the cache behavior definition.', 'CACHE_POLICY_ID_AND_DEPRECATED_FIELDS_USED' ); } const { id, name } = event.cloudFront.cachePolicy; if (name) { if (!this.cachePolicies.has(name)) { throw new this.serverless.classes.Error( `Event references not configured cache policy '${name}'`, 'UNRECOGNIZED_CLOUDFRONT_CACHE_POLICY' ); } unusedUserDefinedCachePolicies.delete(name); } Object.assign(behavior, { CachePolicyId: id || { Ref: this.provider.naming.getCloudFrontCachePolicyLogicalId(name), }, }); // Backward compatibilty - Assinging default cache policy only if none of the ForwardedValues, MinTTL, MaxTTL and DefaultTTL deprecated fields are defined. } else if (shouldAssignCachePolicy) { // Assigning default Managed-CachingOptimized Cache Policy. // See details at https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-policies-list Object.assign(behavior, { CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', }); } const lambdaFunctionAssociation = { EventType: event.cloudFront.eventType, LambdaFunctionARN: { Ref: lambdaVersionLogicalId, }, }; if (event.cloudFront.includeBody != null) { lambdaFunctionAssociation.IncludeBody = event.cloudFront.includeBody; } Object.assign(behavior, { TargetOriginId: origin.Id, LambdaFunctionAssociations: [lambdaFunctionAssociation], }); if (pathPattern) { Object.assign(behavior, { PathPattern: pathPattern }); } const existingBehaviour = behaviors.find( (o) => o.PathPattern === behavior.PathPattern && o.TargetOriginId === behavior.TargetOriginId ); if (!existingBehaviour) { behaviors.push(behavior); } else { behavior = extendDeep(existingBehaviour, behavior); } lambdaAtEdgeFunctions.push( Object.assign({}, functionObj, { functionName, lambdaVersionLogicalId }) ); } }); } }); unusedUserDefinedCachePolicies.forEach((unusedUserDefinedCachePolicy) => { this.serverless.cli.log( `WARNING: provider.cloudFront.cachePolicies.${unusedUserDefinedCachePolicy} ` + 'not used by any cloudFront event configuration' ); }); // sort that first is without PathPattern if available behaviors.sort((a, b) => { if (a.PathPattern && !b.PathPattern) { return 1; } if (b.PathPattern && !a.PathPattern) { return -1; } return 0; }); if (lambdaAtEdgeFunctions.length) { if (this.provider.getRegion() !== 'us-east-1') { throw new ServerlessError( 'CloudFront associated functions have to be deployed to the us-east-1 region.' ); } // Check if all behaviors got unique pathPatterns if (behaviors.length !== _.uniqBy(behaviors, 'PathPattern').length) { throw new ServerlessError('Found more than one behavior with the same PathPattern'); } // Check if all event types in every behavior is unique if ( behaviors.some((o) => { return ( o.LambdaFunctionAssociations.length !== _.uniqBy(o.LambdaFunctionAssociations, 'EventType').length ); }) ) { throw new ServerlessError( 'The event type of a function association must be unique in the cache behavior' ); } // DefaultCacheBehavior does not support PathPattern property if (behaviors[0].PathPattern) { let origin = defaultOrigin; if (!origin) { if (origins.length > 1) { throw new ServerlessError( 'Found more than one origin but none of the cloudfront event has "isDefaultOrigin" defined' ); } origin = origins[0]; } const behavior = _.omit(behaviors[0], ['PathPattern', 'LambdaFunctionAssociations']); behavior.TargetOriginId = origin.Id; behaviors.unshift(behavior); } const lambdaInvokePermissions = lambdaAtEdgeFunctions.reduce( (permissions, lambdaAtEdgeFunction) => { const invokePermissionName = this.provider.naming.getLambdaAtEdgeInvokePermissionLogicalId( lambdaAtEdgeFunction.functionName ); const invokePermission = { Type: 'AWS::Lambda::Permission', Properties: { FunctionName: { Ref: lambdaAtEdgeFunction.lambdaVersionLogicalId, }, Action: 'lambda:InvokeFunction', Principal: 'edgelambda.amazonaws.com', SourceArn: { 'Fn::Join': [ '', [ '', 'arn:', { Ref: 'AWS::Partition' }, ':cloudfront::', { Ref: 'AWS::AccountId' }, ':distribution/', { Ref: this.provider.naming.getCloudFrontDistributionLogicalId() }, ], ], }, }, }; return Object.assign(permissions, { [invokePermissionName]: invokePermission, }); }, {} ); Object.assign(Resources, lambdaInvokePermissions); if (!Resources.IamRoleLambdaExecution) { this.serverless.cli.log( chalk.magenta('Remember to add required lambda@edge permissions to your execution role.') ); this.serverless.cli.log( chalk.magenta( 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-edge-permissions.html' ) ); } else { const lambdaAssumeStatement = Resources.IamRoleLambdaExecution.Properties.AssumeRolePolicyDocument.Statement.find( (statement) => statement.Principal.Service.includes('lambda.amazonaws.com') ); if (lambdaAssumeStatement) { lambdaAssumeStatement.Principal.Service.push('edgelambda.amazonaws.com'); } // Lambda creates CloudWatch Logs log streams // in the CloudWatch Logs regions closest // to the locations where the function is executed. // The format of the name for each log stream is // /aws/lambda/us-east-1.function-name where // function-name is the name that you gave // to the function when you created it. Resources.IamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement.push({ Effect: 'Allow', Action: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], Resource: [{ 'Fn::Sub': 'arn:${AWS::Partition}:logs:*:*:*' }], }); } const CacheBehaviors = behaviors.slice(1); const CloudFrontDistribution = { Type: 'AWS::CloudFront::Distribution', Properties: { DistributionConfig: { Comment: `${this.serverless.service.service} ${this.provider.getStage()}`, Enabled: true, DefaultCacheBehavior: behaviors[0], Origins: origins, }, }, }; if (CacheBehaviors.length > 0) { Object.assign(CloudFrontDistribution.Properties.DistributionConfig, { CacheBehaviors }); } Object.assign(Resources, { [this.cloudFrontDistributionLogicalId]: CloudFrontDistribution }); _.merge(Outputs, { [this.cloudFrontDistributionLogicalId]: { Description: 'CloudFront Distribution Id', Value: { Ref: this.provider.naming.getCloudFrontDistributionLogicalId(), }, }, [this.cloudFrontDistributionDomainNameLogicalId]: { Description: 'CloudFront Distribution Domain Name', Value: { 'Fn::GetAtt': [this.provider.naming.getCloudFrontDistributionLogicalId(), 'DomainName'], }, }, }); } } } module.exports = AwsCompileCloudFrontEvents;