diff --git a/README.md b/README.md index cba17ea..447fed4 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,89 @@ Lambda invocation. This will call the aliased function version. Deployed stages have the alias stage variable set fixed, so a deployed alias stage is hard-wired to the aliased Lambda versions. +### Stage configuration (NEW) + +The alias plugin supports configuring the deployed API Gateway stages, exactly as +you can do it within the AWS APIG console, e.g. you can configure logging (with +or without data/request tracing), setup caching or throttling on your endpoints. + +The configuration can be done on a service wide level, function level or method level +by adding an `aliasStage` object either to `provider`, `any function` or a `http event` +within a function in your _serverless.yml_. The configuration is applied hierarchically, +where the inner configurations overwrite the outer ones. + +`HTTP Event -> FUNCTION -> SERVICE` + +#### The aliasStage configuration object + +All settings are optional, and if not specified will be set to the AWS stage defaults. + +``` +aliasStage: + cacheDataEncrypted: (Boolean) + cacheTtlInSeconds: (Integer) + cachingEnabled: (Boolean) + dataTraceEnabled: (Boolean) - Log full request/response bodies + loggingLevel: ("OFF", "INFO" or "ERROR") + metricsEnabled: (Boolean) - Enable detailed CW metrics + throttlingBurstLimit: (Integer) + throttlingRateLimit: (Number) +``` + +There are two further options that can only be specified on a service level and that +affect the whole stage: + +``` +aliasStage: + cacheClusterEnabled: (Boolean) + cacheClusterSize: (Integer) +``` + +For more information see the [AWS::APIGateway::Stage](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html) or [MethodSettings](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-apitgateway-stage-methodsetting.html) documentation +on the AWS website. + +Sample serverless.yml (partial): + +``` +service: sls-test-project + +provider: + ... + # Enable detailed error logging on all endpoints + aliasStage: + loggingLevel: "ERROR" + dataTraceEnabled: true + ... + +functions: + myFunc1: + ... + # myFunc1 should generally not log anything + aliasStage: + loggingLevel: "OFF" + dataTraceEnabled: false + events: + - http: + method: GET + path: /func1 + - http: + method: POST + path: /func1/create + - http: + method: PATCH + path: /func1/update + # The update endpoint needs special settings + aliasStage: + loggingLevel: "INFO" + dataTraceEnabled: true + throttlingBurstLimit: 200 + throttlingRateLimit: 100 + + myFunc2: + ... + # Will inherit the global settings if nothing is set on function level +``` + ## Reference the current alias in your service You can reference the currently deployed alias with `${self:provider.alias}` in @@ -174,11 +257,11 @@ functions: path: /func1 resources: Resources: - myKinesis: - Type: AWS::Kinesis::Stream - Properties: - Name: my-kinesis - ShardCount: 1 + myKinesis: + Type: AWS::Kinesis::Stream + Properties: + Name: my-kinesis + ShardCount: 1 ``` When a function is deployed to an alias it will now also listen to the *my-kinesis* @@ -214,11 +297,11 @@ functions: path: /func1 resources: Resources: - myKinesis${self:provider.alias}: - Type: AWS::Kinesis::Stream - Properties: - Name: my-kinesis-${self.provider.alias} - ShardCount: 1 + myKinesis${self:provider.alias}: + Type: AWS::Kinesis::Stream + Properties: + Name: my-kinesis-${self.provider.alias} + ShardCount: 1 ``` ### Named streams @@ -343,7 +426,7 @@ The plugin adds the following lifecycle events that can be hooked by other plugi * alias:deploy:done The Alias plugin is successfully finished. Hook this instead of 'after:deploy:deploy' - to make sure that your plugin gets triggered right after the alias plugin is done. + to make sure that your plugin gets triggered right after the alias plugin is done. * alias:remove:removeStack @@ -360,8 +443,8 @@ and _serverless.service.provider.deployedAliasTemplates[]_. * The master alias for a stage could be protected by a separate stack policy that only allows admin users to deploy or change it. The stage stack does not have - to be protected individually because the stack cross references prohibit changes - naturally. It might be possible to introduce some kind of per alias policy. + to be protected individually because the stack cross references prohibit changes + naturally. It might be possible to introduce some kind of per alias policy. ## Version history @@ -381,19 +464,3 @@ and _serverless.service.provider.deployedAliasTemplates[]_. * 1.0.0 Support "serverless logs" with aliases. First non-alpha! -* 0.5.1-alpha1 Use separate Lambda roles per alias -* 0.5.0-alpha1 Fixes a bug with deploying event sources introduced with 0.4.0 - Use new event model introduced in SLS 1.12. Needs SLS 1.12 or greater from now on. - Add support for CW events. - Set SERVERLESS_ALIAS environment variable on deployed functions. -* 0.4.0-alpha1 APIG support fixed. Support external IAM roles. BREAKING. -* 0.3.4-alpha1 Bugfixes. IAM policy consolitaion. Show master alias information. -* 0.3.3-alpha1 Bugfixes. Allow manual resource overrides. Allow methods attached to APIG root resource. -* 0.3.2-alpha1 Allow initial project creation with activated alias plugin -* 0.3.1-alpha1 Support Serverless 1.6 again with upgrade to 1.7+ -* 0.3.0-alpha1 Support lambda event subscriptions -* 0.2.1-alpha1 Alias remove command removes unused resources -* 0.2.0-alpha1 Support custom resources -* 0.1.2-alpha1 Integration with "serverless info" -* 0.1.1-alpha1 Full APIG support -* 0.1.0-alpha1 Lambda function alias support diff --git a/lib/stackops/apiGateway.js b/lib/stackops/apiGateway.js index 0dd270d..181d5ad 100644 --- a/lib/stackops/apiGateway.js +++ b/lib/stackops/apiGateway.js @@ -10,6 +10,103 @@ const _ = require('lodash'); const BbPromise = require('bluebird'); const utils = require('../utils'); +const stageMethodConfigMappings = { + cacheDataEncrypted: { prop: 'CacheDataEncrypted', validate: _.isBoolean, default: false }, + cacheTtlInSeconds: { prop: 'CacheTtlInSeconds', validate: _.isInteger }, + cachingEnabled: { prop: 'CachingEnabled', validate: _.isBoolean, default: false }, + dataTraceEnabled: { prop: 'DataTraceEnabled', validate: _.isBoolean, default: false }, + loggingLevel: { prop: 'LoggingLevel', validate: value => _.includes([ 'OFF', 'INFO', 'ERROR' ], value), default: 'OFF' }, + metricsEnabled: { prop: 'MetricsEnabled', validate: _.isBoolean, default: false }, + throttlingBurstLimit: { prop: 'ThrottlingBurstLimit', validate: _.isInteger }, + throttlingRateLimit: { prop: 'ThrottlingRateLimit', validate: _.isNumber } +}; + +/** + * Namespace for APIG processing internal functions + */ +const internal = { + /** + * Creates a stage resource and configures it depending on the project settings. + * @this The current instance of the alias plugin + * @param restApiRef {String} Stack reference to rest API id + * @param deploymentName {String} Current deployment. + * @returns {Object} - AWS::ApiGateway::Stage + */ + createStageResource(restApiRef, deploymentName) { + // Create stage resource + const stageResource = { + Type: 'AWS::ApiGateway::Stage', + Properties: { + StageName: this._alias, + DeploymentId: { + Ref: deploymentName + }, + RestApiId: { + 'Fn::ImportValue': restApiRef + }, + Variables: { + SERVERLESS_ALIAS: this._alias, + SERVERLESS_STAGE: this._stage + } + }, + DependsOn: [ deploymentName ] + }; + + // Set a reasonable description + const serviceName = _.get(this.serverless.service.getServiceObject() || {}, 'name'); + stageResource.Properties.Description = `Alias stage '${this._alias}' for ${serviceName}`; + + // Configure stage (service level) + const serviceLevelConfig = _.cloneDeep(_.get(this.serverless.service, 'provider.aliasStage', {})); + if (serviceLevelConfig.cacheClusterEnabled === true) { + stageResource.Properties.CacheClusterEnabled = true; + if (_.has(serviceLevelConfig, 'cacheClusterSize')) { + stageResource.Properties.CacheClusterSize = serviceLevelConfig.cacheClusterSize; + } + } + delete serviceLevelConfig.cacheClusterEnabled; + delete serviceLevelConfig.cacheClusterSize; + + // Configure methods/functions + const methodSettings = []; + const functions = this.serverless.service.getAllFunctions(); + _.forEach(functions, funcName => { + const func = this.serverless.service.getFunction(funcName); + const funcStageConfig = _.defaults({}, func.aliasStage, serviceLevelConfig); + const funcHttpEvents = _.compact(_.map(this.serverless.service.getAllEventsInFunction(funcName), event => event.http)); + + _.forEach(funcHttpEvents, httpEvent => { + const eventStageConfig = _.defaults({}, httpEvent.aliasStage, funcStageConfig); + if (!_.isEmpty(eventStageConfig)) { + const methodType = _.toUpper(httpEvent.method); + const methodSetting = {}; + _.forOwn(eventStageConfig, (value, key) => { + if (!_.has(stageMethodConfigMappings, key)) { + throw new this.serverless.classes.Error(`Invalid stage config '${key}' at method '${methodType} /${httpEvent.path}'`); + } else if (!stageMethodConfigMappings[key].validate(value)) { + throw new this.serverless.classes.Error(`Invalid value for stage config '${key}: ${value}' at method '${methodType} /${httpEvent.path}'`); + } + if (!_.has(stageMethodConfigMappings[key], 'default') || stageMethodConfigMappings[key].default !== value) { + methodSetting[stageMethodConfigMappings[key].prop] = value; + } + }); + if (!_.isEmpty(methodSetting)) { + methodSetting.HttpMethod = methodType; + methodSetting.ResourcePath = '/' + _.replace('/' + _.trimStart(httpEvent.path, '/'), /\//g, '~1'); + methodSettings.push(methodSetting); + } + } + }); + }); + + if (!_.isEmpty(methodSettings)) { + stageResource.Properties.MethodSettings = methodSettings; + } + + return stageResource; + } +}; + module.exports = function(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) { const stackName = this._provider.naming.getStackName(); const stageStack = this._serverless.service.provider.compiledCloudFormationTemplate; @@ -74,25 +171,9 @@ module.exports = function(currentTemplate, aliasStackTemplates, currentAliasStac delete stageStack.Resources[deploymentName]; // Create stage resource - const stageResource = { - Type: 'AWS::ApiGateway::Stage', - Properties: { - StageName: this._alias, - DeploymentId: { - Ref: deploymentName - }, - RestApiId: { - 'Fn::ImportValue': `${stackName}-ApiGatewayRestApi` - }, - Variables: { - SERVERLESS_ALIAS: this._alias, - SERVERLESS_STAGE: this._stage - } - }, - DependsOn: [ deploymentName ] - }; + this.options.verbose && this._serverless.cli.log('Configuring stage'); + const stageResource = internal.createStageResource.call(this, `${stackName}-ApiGatewayRestApi`, deploymentName); aliasResources.push({ ApiGatewayStage: stageResource }); - } // Fetch lambda permissions, methods and resources. These have to be updated later to allow the aliased functions. @@ -207,3 +288,6 @@ module.exports = function(currentTemplate, aliasStackTemplates, currentAliasStac return BbPromise.resolve([ currentTemplate, aliasStackTemplates, currentAliasStackTemplate ]); }; + +// Exports to make internal functions available for unit tests +module.exports.internal = internal; diff --git a/test/stackops/apiGateway.test.js b/test/stackops/apiGateway.test.js index 071bdaa..4e5a859 100644 --- a/test/stackops/apiGateway.test.js +++ b/test/stackops/apiGateway.test.js @@ -15,6 +15,7 @@ const AwsProvider = require(`${serverlessPath}/lib/plugins/aws/provider/awsProvi const Serverless = require(`${serverlessPath}/lib/Serverless`); chai.use(require('chai-as-promised')); +chai.use(require('chai-subset')); chai.use(require('sinon-chai')); const expect = chai.expect; @@ -55,6 +56,509 @@ describe('API Gateway', () => { sandbox.restore(); }); + describe('#createStageResource()', () => { + let createStageResource; + + beforeEach(() => { + createStageResource = _.bind(require('../../lib/stackops/apiGateway').internal.createStageResource, awsAlias); + }); + + it('should not throw with simple service', () => { + expect(() => createStageResource('apiRef', 'deployment')).to.not.throw; + }); + + it('should return a valid stage object', () => { + const stage = createStageResource('apiRef', 'deployment'); + + expect(stage).to.have.a.property('Type', 'AWS::ApiGateway::Stage'); + expect(stage).to.have.a.deep.property('Properties.StageName', 'myAlias'); + expect(stage).to.have.a.deep.property('Properties.DeploymentId').that.is.an('object').that.deep.equals({ Ref: 'deployment' }); + expect(stage).to.have.a.deep.property('Properties.RestApiId').that.is.an('object').that.deep.equals({ 'Fn::ImportValue': 'apiRef' }); + expect(stage).to.have.a.deep.property('Properties.Variables').that.is.an('object').that.containSubset({ SERVERLESS_ALIAS: 'myAlias' }); + }); + + it('should set general stage configuration', () => { + awsAlias.serverless.service.provider.aliasStage = { + cacheClusterEnabled: true, + cacheClusterSize: 2 + }; + + const stage = createStageResource('apiRef', 'deployment'); + expect(stage).to.have.a.deep.property('Properties.CacheClusterEnabled', true); + expect(stage).to.have.a.deep.property('Properties.CacheClusterSize', 2); + }); + + it('should omit cacheClusterSize if not given', () => { + awsAlias.serverless.service.provider.aliasStage = { + cacheClusterEnabled: true + }; + + const stage = createStageResource('apiRef', 'deployment'); + expect(stage).to.not.have.a.deep.property('Properties.CacheClusterSize'); + }); + + it('should throw on invalid configuration keys', () => { + const service = { + service: 'testService', + serviceObject: { + name: 'testService' + }, + provider: { + name: 'aws', + runtime: 'nodejs4.3', + stage: 'myStage', + alias: 'myAlias', + region: 'us-east-1', + aliasStage: { + notSomethingUnderstood: 'INFO', + cacheClusterSize: 2, + metricsEnabled: true + } + }, + functions: { + functionA: { + handler: 'functionA.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcA' + } + }, + ], + } + } + }; + + awsAlias.serverless.service = new awsAlias.serverless.classes.Service(awsAlias.serverless, service); + + expect(() => createStageResource('apiRef', 'deployment')).to.throw('Invalid stage config'); + }); + + it('should throw on invalid configuration values', () => { + const service = { + service: 'testService', + serviceObject: { + name: 'testService' + }, + provider: { + name: 'aws', + runtime: 'nodejs4.3', + stage: 'myStage', + alias: 'myAlias', + region: 'us-east-1', + aliasStage: { + cacheClusterSize: 2, + metricsEnabled: 'true' + } + }, + functions: { + functionA: { + handler: 'functionA.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcA' + } + }, + ], + } + } + }; + + awsAlias.serverless.service = new awsAlias.serverless.classes.Service(awsAlias.serverless, service); + + expect(() => createStageResource('apiRef', 'deployment')).to.throw('Invalid value for'); + }); + + it('should use service config', () => { + const service = { + service: 'testService', + serviceObject: { + name: 'testService' + }, + provider: { + name: 'aws', + runtime: 'nodejs4.3', + stage: 'myStage', + alias: 'myAlias', + region: 'us-east-1', + aliasStage: { + loggingLevel: 'INFO' + } + }, + functions: { + functionA: { + handler: 'functionA.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcA' + } + }, + { + http: { + method: 'POST', + path: '/funcA/create' + } + } + ] + }, + functionB: { + handler: 'functionB.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcB' + } + }, + { + http: { + method: 'UPDATE', + path: '/funcB/update' + } + } + ] + }, + } + }; + const expectedMethodSettings = [ + { + LoggingLevel: 'INFO', + HttpMethod: 'GET', + ResourcePath: '/~1funcA' + }, + { + LoggingLevel: 'INFO', + HttpMethod: 'POST', + ResourcePath: '/~1funcA~1create' + }, + { + LoggingLevel: 'INFO', + HttpMethod: 'GET', + ResourcePath: '/~1funcB' + }, + { + LoggingLevel: 'INFO', + HttpMethod: 'UPDATE', + ResourcePath: '/~1funcB~1update' + }, + ]; + + awsAlias.serverless.service = new awsAlias.serverless.classes.Service(awsAlias.serverless, service); + + const stage = createStageResource('apiRef', 'deployment'); + expect(stage).to.have.a.deep.property('Properties.MethodSettings').that.deep.equals(expectedMethodSettings); + }); + + it('should prefer function config', () => { + const service = { + service: 'testService', + serviceObject: { + name: 'testService' + }, + provider: { + name: 'aws', + runtime: 'nodejs4.3', + stage: 'myStage', + alias: 'myAlias', + region: 'us-east-1', + aliasStage: { + loggingLevel: 'INFO' + } + }, + functions: { + functionA: { + handler: 'functionA.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcA' + } + }, + { + http: { + method: 'POST', + path: '/funcA/create' + } + } + ] + }, + functionB: { + handler: 'functionB.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcB' + } + }, + { + http: { + method: 'UPDATE', + path: '/funcB/update' + } + } + ], + aliasStage: { + loggingLevel: 'ERROR', + metricsEnabled: true + } + }, + } + }; + const expectedMethodSettings = [ + { + LoggingLevel: 'INFO', + HttpMethod: 'GET', + ResourcePath: '/~1funcA' + }, + { + LoggingLevel: 'INFO', + HttpMethod: 'POST', + ResourcePath: '/~1funcA~1create' + }, + { + LoggingLevel: 'ERROR', + MetricsEnabled: true, + HttpMethod: 'GET', + ResourcePath: '/~1funcB' + }, + { + LoggingLevel: 'ERROR', + MetricsEnabled: true, + HttpMethod: 'UPDATE', + ResourcePath: '/~1funcB~1update' + }, + ]; + + awsAlias.serverless.service = new awsAlias.serverless.classes.Service(awsAlias.serverless, service); + + const stage = createStageResource('apiRef', 'deployment'); + expect(stage).to.have.a.deep.property('Properties.MethodSettings').that.deep.equals(expectedMethodSettings); + }); + + it('should prefer event config', () => { + const service = { + service: 'testService', + serviceObject: { + name: 'testService' + }, + provider: { + name: 'aws', + runtime: 'nodejs4.3', + stage: 'myStage', + alias: 'myAlias', + region: 'us-east-1', + aliasStage: { + loggingLevel: 'INFO' + } + }, + functions: { + functionA: { + handler: 'functionA.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcA' + } + }, + { + http: { + method: 'POST', + path: '/funcA/create', + aliasStage: { + metricsEnabled: true + } + } + } + ] + }, + functionB: { + handler: 'functionB.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcB' + } + }, + { + http: { + method: 'UPDATE', + path: '/funcB/update', + aliasStage: { + loggingLevel: 'INFO', + cachingEnabled: true, + } + } + } + ], + aliasStage: { + loggingLevel: 'ERROR', + metricsEnabled: true, + } + }, + } + }; + const expectedMethodSettings = [ + { + LoggingLevel: 'INFO', + HttpMethod: 'GET', + ResourcePath: '/~1funcA' + }, + { + LoggingLevel: 'INFO', + MetricsEnabled: true, + HttpMethod: 'POST', + ResourcePath: '/~1funcA~1create' + }, + { + LoggingLevel: 'ERROR', + MetricsEnabled: true, + HttpMethod: 'GET', + ResourcePath: '/~1funcB' + }, + { + LoggingLevel: 'INFO', + MetricsEnabled: true, + CachingEnabled: true, + HttpMethod: 'UPDATE', + ResourcePath: '/~1funcB~1update' + }, + ]; + + awsAlias.serverless.service = new awsAlias.serverless.classes.Service(awsAlias.serverless, service); + + const stage = createStageResource('apiRef', 'deployment'); + expect(stage).to.have.a.deep.property('Properties.MethodSettings').that.deep.equals(expectedMethodSettings); + }); + + it('should not set AWS default values', () => { + const service = { + service: 'testService', + serviceObject: { + name: 'testService' + }, + provider: { + name: 'aws', + runtime: 'nodejs4.3', + stage: 'myStage', + alias: 'myAlias', + region: 'us-east-1', + aliasStage: { + loggingLevel: 'INFO' + } + }, + functions: { + functionB: { + handler: 'functionB.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcB' + } + }, + { + http: { + method: 'UPDATE', + path: '/funcB/update', + aliasStage: { + loggingLevel: 'ERROR', + } + } + }, + { + http: { + method: 'PATCH', + path: '/funcB/update', + aliasStage: { + loggingLevel: 'OFF', + metricsEnabled: false + } + } + } + ], + aliasStage: { + loggingLevel: 'OFF', + metricsEnabled: true + } + }, + } + }; + const expectedMethodSettings = [ + { + MetricsEnabled: true, + HttpMethod: 'GET', + ResourcePath: '/~1funcB' + }, + { + LoggingLevel: 'ERROR', + MetricsEnabled: true, + HttpMethod: 'UPDATE', + ResourcePath: '/~1funcB~1update' + }, + ]; + + awsAlias.serverless.service = new awsAlias.serverless.classes.Service(awsAlias.serverless, service); + + const stage = createStageResource('apiRef', 'deployment'); + expect(stage).to.have.a.deep.property('Properties.MethodSettings').that.deep.equals(expectedMethodSettings); + }); + + it('should not set stage config without actual configuration', () => { + const service = { + service: 'testService', + serviceObject: { + name: 'testService' + }, + provider: { + name: 'aws', + runtime: 'nodejs4.3', + stage: 'myStage', + alias: 'myAlias', + region: 'us-east-1', + }, + functions: { + functionB: { + handler: 'functionB.handler', + events: [ + { + http: { + method: 'GET', + path: '/funcB' + } + }, + { + http: { + method: 'UPDATE', + path: '/funcB/update', + } + }, + { + http: { + method: 'PATCH', + path: '/funcB/update', + } + } + ], + }, + } + }; + + awsAlias.serverless.service = new awsAlias.serverless.classes.Service(awsAlias.serverless, service); + + const stage = createStageResource('apiRef', 'deployment'); + expect(stage).to.not.have.a.deep.property('Properties.MethodSettings'); + }); + }); + describe('#aliasHandleApiGateway()', () => { it('should succeed with standard template', () => { serverless.service.provider.compiledCloudFormationTemplate = require('../data/sls-stack-1.json');