import _ from 'lodash' import url from 'url' import ServerlessError from '../../../../../serverless-error.js' import utils from '@serverlessinc/sf-core/src/utils.js' const { log, style } = utils 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', minimum: 1, maximum: 3 }, ConnectionTimeout: { type: 'integer', minimum: 1, maximum: 10 }, CustomOriginConfig: { type: 'object', properties: { HTTPPort: { type: 'integer', minimum: 0, maximum: 65535 }, HTTPSPort: { type: 'integer', minimum: 0, maximum: 65535 }, OriginKeepaliveTimeout: { type: 'integer', minimum: 1, maximum: 60, }, OriginProtocolPolicy: { enum: ['http-only', 'match-viewer', 'https-only'], }, OriginReadTimeout: { type: 'integer', minimum: 1, maximum: 60 }, OriginSSLProtocols: { type: 'array', items: { enum: ['SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2'] }, }, }, additionalProperties: false, required: ['OriginProtocolPolicy'], }, DomainName: { anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfFunction' }], }, OriginAccessControlId: { anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfFunction' }], }, 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: { anyOf: [ { type: 'string', pattern: '^origin-access-identity/cloudfront/.+', }, { $ref: '#/definitions/awsCfFunction' }, ], }, }, additionalProperties: false, }, }, additionalProperties: false, required: ['DomainName'], 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'] }, }, ], }, CachePolicyId: { type: 'string' }, Compress: { type: 'boolean' }, FieldLevelEncryptionId: { type: 'string' }, OriginRequestPolicyId: { anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfFunction' }], }, ResponseHeadersPolicyId: { anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfFunction' }], }, SmoothStreaming: { type: 'boolean' }, TrustedSigners: { type: 'array', items: { type: 'string' } }, ViewerProtocolPolicy: { enum: ['allow-all', 'redirect-to-https', 'https-only'], }, TrustedKeyGroups: { type: 'array', items: { anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfRef' }], }, }, MaxTTL: { type: 'number' }, MinTTL: { type: 'number' }, DefaultTTL: { type: 'number' }, 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'], }, }, 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], }, // Allowed characters reference: // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/distribution-web-values-specify.html#DownloadDistValuesPathPattern // Still note it doesn't reference "?" character, which appears in prior examples, // Hence it's now included in this regex pathPattern: { type: 'string', pattern: '^([A-Za-z0-9_.*?$/~"\'@:+-]|&)+$', }, }, additionalProperties: false, }, ) this.hooks = { 'package:initialize': async () => this.validate(), 'before:package:compileFunctions': async () => this.prepareFunctions(), 'package:compileEvents': () => { this.compileCloudFrontCachePolicies() this.compileCloudFrontEvents() }, 'before:remove:remove': async () => this.logRemoveReminder(), } } 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('') log.warning(message) } } } 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 ServerlessError( `"${functionName}" memorySize is greater than ${maxMemorySize} which is not supported by Lambda@Edge functions of type "${eventType}"`, 'LAMBDA_EDGE_UNSUPPORTED_MEMORY_SIZE', ) } if (functionObj.timeout && functionObj.timeout > maxTimeout) { throw new ServerlessError( `"${functionName}" timeout is greater than ${maxTimeout} which is not supported by Lambda@Edge functions of type "${eventType}"`, 'LAMBDA_EDGE_UNSUPPORTED_TIMEOUT_VALUE', ) } }) }) } 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: this.provider.naming.getCloudFrontCachePolicyName(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', 'CLOUDFRONT_MULTIPLE_DEFAULT_ORIGIN_EVENTS', ) } defaultOrigin = origin } let behavior = { ViewerProtocolPolicy: 'allow-all', } let shouldAssignCachePolicy = true if (event.cloudFront.behavior) { Object.assign(behavior, event.cloudFront.behavior) } if ( event.cloudFront.behavior && event.cloudFront.behavior.CachePolicyId ) { Object.assign(behavior, { CachePolicyId: event.cloudFront.behavior.CachePolicyId, }) shouldAssignCachePolicy = false } if ( event.cloudFront.behavior && (event.cloudFront.behavior.ForwardedValues || event.cloudFront.behavior.MaxTTL != null || event.cloudFront.behavior.MinTTL != null || event.cloudFront.behavior.DefaultTTL != null) ) { shouldAssignCachePolicy = false } if (event.cloudFront.cachePolicy) { const { id, name } = event.cloudFront.cachePolicy if (name) { if (!this.cachePolicies.has(name)) { throw new ServerlessError( `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, ), }, }) shouldAssignCachePolicy = false } // Assigning default cache policy only if cache policy reference is not defined. 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) => { log.warning( `Setting "provider.cloudFront.cachePolicies.${unusedUserDefinedCachePolicy}" is 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.', 'CLOUDFRONT_INVALID_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', 'CLOUDFRONT_MULTIPLE_BEHAVIORS_FOR_SINGLE_PATH_PATTERN', ) } // 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', 'CLOUDFRONT_EVENT_TYPE_NON_UNIQUE_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', 'CLOUDFRONT_MULTIPLE_DEFAULT_ORIGIN_EVENTS', ) } 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) { log.notice( `Remember to add required lambda@edge permissions to your execution role. Documentation: ${style.link( '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', 'logs:TagResource', ], 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', ], }, }, }) } } } export default AwsCompileCloudFrontEvents