UNPKG

ask-serverless-plugin-canary-deployments

Version:

A Serverless plugin to implement canary deployment of Lambda functions

447 lines (400 loc) 16.4 kB
const _ = require('lodash/fp') const flattenObject = require('flat') const CfGenerators = require('./lib/CfTemplateGenerators') const { customPropertiesSchema, functionPropertiesSchema } = require('./configSchemas') const slsHasConfigSchema = sls => sls.configSchemaHandler && sls.configSchemaHandler.defineCustomProperties && sls.configSchemaHandler.defineFunctionProperties class ServerlessCanaryDeployments { constructor (serverless, options) { this.serverless = serverless this.options = options this.awsProvider = this.serverless.getProvider('aws') this.naming = this.awsProvider.naming this.service = this.serverless.service this.hooks = { 'after:aws:package:finalize:mergeCustomProviderResources': this.addCanaryDeploymentResources.bind(this) } this.addConfigSchema() } get codeDeployAppName () { const stackName = this.naming.getStackName() const normalizedStackName = this.naming.normalizeNameToAlphaNumericOnly(stackName) return `${normalizedStackName}DeploymentApplication` } get compiledTpl () { return this.service.provider.compiledCloudFormationTemplate } get withDeploymentPreferencesFns () { return this.serverless.service.getAllFunctions() .filter(name => _.has('deploymentSettings', this.service.getFunction(name))) } get globalSettings () { return _.pathOr({}, 'custom.deploymentSettings', this.service) } get currentStage () { return this.awsProvider.getStage() } addConfigSchema () { if (slsHasConfigSchema(this.serverless)) { this.serverless.configSchemaHandler.defineCustomProperties(customPropertiesSchema) this.serverless.configSchemaHandler.defineFunctionProperties('aws', functionPropertiesSchema) } } addCanaryDeploymentResources () { if (this.shouldDeployDeployGradually()) { const codeDeployApp = this.buildCodeDeployApp() const functionsResources = this.buildFunctionsResources() const codeDeployRole = this.buildCodeDeployRole(this.areTriggerConfigurationsSet(functionsResources)) const executionRole = this.buildExecutionRole() Object.assign( this.compiledTpl.Resources, codeDeployApp, codeDeployRole, executionRole, ...functionsResources ) } } areTriggerConfigurationsSet (functionsResources) { // Checking if the template has trigger configurations. for (const resource of functionsResources) { for (const key of Object.keys(resource)) { if (resource[key].Type === 'AWS::CodeDeploy::DeploymentGroup') { if (resource[key].Properties.TriggerConfigurations) { return true } } } } return false } shouldDeployDeployGradually () { return this.withDeploymentPreferencesFns.length > 0 && this.currentStageEnabled() } currentStageEnabled () { const enabledStages = _.getOr([], 'stages', this.globalSettings) return _.isEmpty(enabledStages) || _.includes(this.currentStage, enabledStages) } buildExecutionRole () { const logicalName = this.naming.getRoleLogicalId() const inputRole = this.compiledTpl.Resources[logicalName] if (!inputRole) { return } const hasHook = _.pipe( this.getDeploymentSettingsFor.bind(this), settings => settings.preTrafficHook || settings.postTrafficHook ) const getDeploymentGroup = _.pipe( this.getFunctionName.bind(this), this.getFunctionDeploymentGroupId.bind(this), this.getDeploymentGroupName.bind(this) ) const deploymentGroups = _.pipe( _.filter(hasHook), _.map(getDeploymentGroup) )(this.withDeploymentPreferencesFns) const outputRole = CfGenerators.iam.buildExecutionRoleWithCodeDeploy(inputRole, this.codeDeployAppName, deploymentGroups) return { [logicalName]: outputRole } } buildFunctionsResources () { return _.flatMap( serverlessFunction => this.buildFunctionResources(serverlessFunction), this.withDeploymentPreferencesFns ) } buildFunctionResources (serverlessFnName) { const functionName = this.naming.getLambdaLogicalId(serverlessFnName) const deploymentSettings = this.getDeploymentSettingsFor(serverlessFnName) const deploymentGrTpl = this.buildFunctionDeploymentGroup({ deploymentSettings, functionName }) const deploymentGroup = this.getResourceLogicalName(deploymentGrTpl) const aliasTpl = this.buildFunctionAlias({ deploymentSettings, functionName, deploymentGroup }) const functionAlias = this.getResourceLogicalName(aliasTpl) const lambdaPermissions = this.buildPermissionsForAlias({ functionName, functionAlias }) const eventsWithAlias = this.buildEventsForAlias({ functionName, functionAlias }) return [deploymentGrTpl, aliasTpl, ...lambdaPermissions, ...eventsWithAlias] } buildCodeDeployApp () { const logicalName = this.codeDeployAppName const template = CfGenerators.codeDeploy.buildApplication() return { [logicalName]: template } } buildCodeDeployRole (areTriggerConfigurationsSet) { if (this.globalSettings.codeDeployRole) return {} const logicalName = 'CodeDeployServiceRole' const template = CfGenerators.iam.buildCodeDeployRole(this.globalSettings.codeDeployRolePermissionsBoundary, areTriggerConfigurationsSet) return { [logicalName]: template } } buildFunctionDeploymentGroup ({ deploymentSettings, functionName }) { const logicalName = this.getFunctionDeploymentGroupId(functionName) const codeDeployGroupName = this.getDeploymentGroupName(logicalName) const params = { codeDeployAppName: this.codeDeployAppName, codeDeployGroupName, codeDeployRoleArn: deploymentSettings.codeDeployRole, deploymentSettings } const template = CfGenerators.codeDeploy.buildFnDeploymentGroup(params) return { [logicalName]: template } } buildFunctionAlias ({ deploymentSettings = {}, functionName, deploymentGroup }) { const { alias } = deploymentSettings const functionVersion = this.getVersionNameFor(functionName) const logicalName = `${functionName}Alias${alias}` const beforeHook = this.getFunctionName(deploymentSettings.preTrafficHook) const afterHook = this.getFunctionName(deploymentSettings.postTrafficHook) const trafficShiftingSettings = { codeDeployApp: this.codeDeployAppName, deploymentGroup, afterHook, beforeHook } const template = CfGenerators.lambda.buildAlias({ alias, functionName, functionVersion, trafficShiftingSettings }) return { [logicalName]: template } } getFunctionDeploymentGroupId (functionLogicalId) { return `${functionLogicalId}DeploymentGroup` } getDeploymentGroupName (deploymentGroupLogicalId) { return `${this.naming.getStackName()}-${deploymentGroupLogicalId}`.slice(0, 100) } getFunctionName (slsFunctionName) { return slsFunctionName ? this.naming.getLambdaLogicalId(slsFunctionName) : null } buildPermissionsForAlias ({ functionName, functionAlias }) { const permissions = this.getLambdaPermissionsFor(functionName) return _.entries(permissions).map(([logicalName, template]) => { const templateWithAlias = CfGenerators.lambda .replacePermissionFunctionWithAlias(template, functionAlias) return { [logicalName]: templateWithAlias } }) } buildEventsForAlias ({ functionName, functionAlias }) { const replaceAliasStrategy = { 'AWS::Lambda::EventSourceMapping': CfGenerators.lambda.replaceEventMappingFunctionWithAlias, 'AWS::ApiGateway::Method': CfGenerators.apiGateway.replaceMethodUriWithAlias, 'AWS::ApiGatewayV2::Integration': CfGenerators.apiGateway.replaceV2IntegrationUriWithAlias, 'AWS::ApiGatewayV2::Authorizer': CfGenerators.apiGateway.replaceV2AuthorizerUriWithAlias, 'AWS::SNS::Topic': CfGenerators.sns.replaceTopicSubscriptionFunctionWithAlias, 'AWS::SNS::Subscription': CfGenerators.sns.replaceSubscriptionFunctionWithAlias, 'AWS::S3::Bucket': CfGenerators.s3.replaceS3BucketFunctionWithAlias, 'AWS::Events::Rule': CfGenerators.cloudWatchEvents.replaceCloudWatchEventRuleTargetWithAlias, 'AWS::Logs::SubscriptionFilter': CfGenerators.cloudWatchLogs.replaceCloudWatchLogsDestinationArnWithAlias, 'AWS::IoT::TopicRule': CfGenerators.iot.replaceIotTopicRuleActionArnWithAlias, 'AWS::AppSync::DataSource': CfGenerators.appSync.replaceAppSyncDataSourceWithAlias } const functionEvents = this.getEventsFor(functionName) const functionEventsEntries = _.entries(functionEvents) const eventsWithAlias = functionEventsEntries.map(([logicalName, event]) => { const evt = replaceAliasStrategy[event.Type](event, functionAlias, functionName) return { [logicalName]: evt } }) return eventsWithAlias } getEventsFor (functionName) { const apiGatewayMethods = this.getApiGatewayMethodsFor(functionName) const apiGatewayV2Methods = this.getApiGatewayV2MethodsFor(functionName) const apiGatewayV2Authorizers = this.getApiGatewayV2AuthorizersFor(functionName) const eventSourceMappings = this.getEventSourceMappingsFor(functionName) const snsTopics = this.getSnsTopicsFor(functionName) const snsSubscriptions = this.getSnsSubscriptionsFor(functionName) const s3Events = this.getS3EventsFor(functionName) const cloudWatchEvents = this.getCloudWatchEventsFor(functionName) const cloudWatchLogs = this.getCloudWatchLogsFor(functionName) const iotTopicRules = this.getIotTopicRulesFor(functionName) const appSyncDataSources = this.getAppSyncDataSourcesFor(functionName) return Object.assign( {}, apiGatewayMethods, apiGatewayV2Methods, apiGatewayV2Authorizers, eventSourceMappings, snsTopics, s3Events, cloudWatchEvents, cloudWatchLogs, snsSubscriptions, iotTopicRules, appSyncDataSources ) } getApiGatewayMethodsFor (functionName) { const isApiGMethod = _.matchesProperty('Type', 'AWS::ApiGateway::Method') const isMethodForFunction = _.pipe( _.prop('Properties.Integration'), flattenObject, _.includes(functionName) ) const getMethodsForFunction = _.pipe( _.pickBy(isApiGMethod), _.pickBy(isMethodForFunction) ) return getMethodsForFunction(this.compiledTpl.Resources) } getApiGatewayV2MethodsFor (functionName) { const isApiGMethod = _.matchesProperty('Type', 'AWS::ApiGatewayV2::Integration') const isMethodForFunction = _.pipe( _.prop('Properties.IntegrationUri'), flattenObject, _.includes(functionName) ) const getMethodsForFunction = _.pipe( _.pickBy(isApiGMethod), _.pickBy(isMethodForFunction) ) return getMethodsForFunction(this.compiledTpl.Resources) } getApiGatewayV2AuthorizersFor (functionName) { const isApiGMethod = _.matchesProperty('Type', 'AWS::ApiGatewayV2::Authorizer') const isMethodForFunction = _.pipe( _.prop('Properties.AuthorizerUri'), flattenObject, _.includes(functionName) ) const getMethodsForFunction = _.pipe( _.pickBy(isApiGMethod), _.pickBy(isMethodForFunction) ) return getMethodsForFunction(this.compiledTpl.Resources) } getEventSourceMappingsFor (functionName) { const isEventSourceMapping = _.matchesProperty('Type', 'AWS::Lambda::EventSourceMapping') const isMappingForFunction = _.pipe( _.prop('Properties.FunctionName'), flattenObject, _.includes(functionName) ) const getMappingsForFunction = _.pipe( _.pickBy(isEventSourceMapping), _.pickBy(isMappingForFunction) ) return getMappingsForFunction(this.compiledTpl.Resources) } getSnsTopicsFor (functionName) { const isSnsTopic = _.matchesProperty('Type', 'AWS::SNS::Topic') const isMappingForFunction = _.pipe( _.prop('Properties.Subscription'), _.map(_.prop('Endpoint.Fn::GetAtt')), _.flatten, _.includes(functionName) ) const getMappingsForFunction = _.pipe( _.pickBy(isSnsTopic), _.pickBy(isMappingForFunction) ) return getMappingsForFunction(this.compiledTpl.Resources) } getSnsSubscriptionsFor (functionName) { const isSnsSubscription = _.matchesProperty('Type', 'AWS::SNS::Subscription') const isSubscriptionForFunction = _.matchesProperty('Properties.Endpoint.Fn::GetAtt[0]', functionName) const getMappingsForFunction = _.pipe( _.pickBy(isSnsSubscription), _.pickBy(isSubscriptionForFunction) ) return getMappingsForFunction(this.compiledTpl.Resources) } getCloudWatchEventsFor (functionName) { const isCloudWatchEvent = _.matchesProperty('Type', 'AWS::Events::Rule') const isCwEventForFunction = _.pipe( _.prop('Properties.Targets'), _.map(_.prop('Arn.Fn::GetAtt')), _.flatten, _.includes(functionName) ) const getMappingsForFunction = _.pipe( _.pickBy(isCloudWatchEvent), _.pickBy(isCwEventForFunction) ) return getMappingsForFunction(this.compiledTpl.Resources) } getCloudWatchLogsFor (functionName) { const isLogSubscription = _.matchesProperty('Type', 'AWS::Logs::SubscriptionFilter') const isLogSubscriptionForFn = _.pipe( _.prop('Properties.DestinationArn.Fn::GetAtt'), _.flatten, _.includes(functionName) ) const getMappingsForFunction = _.pipe( _.pickBy(isLogSubscription), _.pickBy(isLogSubscriptionForFn) ) return getMappingsForFunction(this.compiledTpl.Resources) } getS3EventsFor (functionName) { const isS3Event = _.matchesProperty('Type', 'AWS::S3::Bucket') const isS3EventForFunction = _.pipe( _.prop('Properties.NotificationConfiguration.LambdaConfigurations'), _.map(_.prop('Function.Fn::GetAtt')), _.flatten, _.includes(functionName) ) const getMappingsForFunction = _.pipe( _.pickBy(isS3Event), _.pickBy(isS3EventForFunction) ) return getMappingsForFunction(this.compiledTpl.Resources) } getIotTopicRulesFor (functionName) { const isIotTopicRule = _.matchesProperty('Type', 'AWS::IoT::TopicRule') const isIotTopicRuleForFunction = _.matchesProperty( 'Properties.TopicRulePayload.Actions[0].Lambda.FunctionArn.Fn::GetAtt[0]', functionName ) const getMappingsForFunction = _.pipe( _.pickBy(isIotTopicRule), _.pickBy(isIotTopicRuleForFunction) ) return getMappingsForFunction(this.compiledTpl.Resources) } getAppSyncDataSourcesFor (functionName) { const isAppSyncDataSource = _.matchesProperty('Type', 'AWS::AppSync::DataSource') const isAppSyncDataSourceForFunction = _.matchesProperty( 'Properties.LambdaConfig.LambdaFunctionArn.Fn::GetAtt[0]', functionName ) const getMappingsForFunction = _.pipe( _.pickBy(isAppSyncDataSource), _.pickBy(isAppSyncDataSourceForFunction) ) return getMappingsForFunction(this.compiledTpl.Resources) } getVersionNameFor (functionName) { const isLambdaVersion = _.matchesProperty('Type', 'AWS::Lambda::Version') const isVersionForFunction = _.matchesProperty('Properties.FunctionName.Ref', functionName) const getVersionNameForFunction = _.pipe( _.pickBy(isLambdaVersion), _.findKey(isVersionForFunction) ) return getVersionNameForFunction(this.compiledTpl.Resources) } getLambdaPermissionsFor (functionName) { const isLambdaPermission = _.matchesProperty('Type', 'AWS::Lambda::Permission') const isPermissionForFunction = _.cond([ [_.prop('Properties.FunctionName.Fn::GetAtt[0]'), _.matchesProperty('Properties.FunctionName.Fn::GetAtt[0]', functionName)], [_.prop('Properties.FunctionName.Ref'), _.matchesProperty('Properties.FunctionName.Ref', functionName)] ]) const getPermissionForFunction = _.pipe( _.pickBy(isLambdaPermission), _.pickBy(isPermissionForFunction) ) return getPermissionForFunction(this.compiledTpl.Resources) } getResourceLogicalName (resource) { return _.head(_.keys(resource)) } getDeploymentSettingsFor (slsFunctionName) { const fnDeploymentSetting = this.service.getFunction(slsFunctionName).deploymentSettings return Object.assign({}, this.globalSettings, fnDeploymentSetting) } } module.exports = ServerlessCanaryDeployments