diff --git a/docs/02-providers/aws/README.md b/docs/02-providers/aws/README.md index 22bd41b18..7472a0796 100644 --- a/docs/02-providers/aws/README.md +++ b/docs/02-providers/aws/README.md @@ -22,9 +22,13 @@ provider: runtime: nodejs4.3 # Runtime used for all functions in this provider stage: dev # Set the default stage used. Default is dev region: us-east-1 # Overwrite the default region used. Default is us-east-1 + deploymentBucket: com.serverless.${self:provider.region}.deploys # Overwrite the default deployment bucket variableSyntax: '\${{([\s\S]+?)}}' # Overwrite the default "${}" variable syntax to be "${{}}" instead. This can be helpful if you want to use "${}" as a string without using it as a variable. ``` +### Deployment S3Bucket +The bucket must exist beforehand and be in the same region as the deploy. + ## Additional function configuration ```yaml diff --git a/lib/classes/Error.js b/lib/classes/Error.js index abbda299b..e754e4d21 100644 --- a/lib/classes/Error.js +++ b/lib/classes/Error.js @@ -2,10 +2,11 @@ const chalk = require('chalk'); module.exports.SError = class ServerlessError extends Error { - constructor(message) { + constructor(message, statusCode) { super(message); this.name = this.constructor.name; this.message = message; + this.statusCode = statusCode; Error.captureStackTrace(this, this.constructor); } }; diff --git a/lib/plugins/aws/deploy/compile/functions/index.js b/lib/plugins/aws/deploy/compile/functions/index.js index 4567c23cb..bbdaf3fce 100644 --- a/lib/plugins/aws/deploy/compile/functions/index.js +++ b/lib/plugins/aws/deploy/compile/functions/index.js @@ -9,185 +9,139 @@ class AwsCompileFunctions { this.options = options; this.provider = 'aws'; + this.compileFunctions = this.compileFunctions.bind(this); + this.compileFunction = this.compileFunction.bind(this); + this.hooks = { - 'deploy:compileFunctions': this.compileFunctions.bind(this), + 'deploy:compileFunctions': this.compileFunctions, }; } - compileFunctions() { - if (typeof this.serverless.service.provider.iamRoleARN !== 'string') { - // merge in the iamRoleLambdaTemplate - const iamRoleLambdaExecutionTemplate = this.serverless.utils.readFileSync( - path.join(this.serverless.config.serverlessPath, - 'plugins', - 'aws', - 'deploy', - 'compile', - 'functions', - 'iam-role-lambda-execution-template.json') - ); + compileFunction(functionName) { + const newFunction = this.cfLambdaFunctionTemplate(); + const functionObject = this.serverless.service.getFunction(functionName); - _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, - iamRoleLambdaExecutionTemplate); + const artifactFilePath = this.serverless.service.package.individually ? + functionObject.artifact : + this.serverless.service.package.artifact; - // merge in the iamPolicyLambdaTemplate - const iamPolicyLambdaExecutionTemplate = this.serverless.utils.readFileSync( - path.join(this.serverless.config.serverlessPath, - 'plugins', - 'aws', - 'deploy', - 'compile', - 'functions', - 'iam-policy-lambda-execution-template.json') - ); - - _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, - iamPolicyLambdaExecutionTemplate); - - // set the necessary variables for the IamPolicyLambda - this.serverless.service.provider.compiledCloudFormationTemplate - .Resources - .IamPolicyLambdaExecution - .Properties - .PolicyName = `${this.options.stage}-${this.serverless.service.service}-lambda`; - this.serverless.service.provider.compiledCloudFormationTemplate - .Resources - .IamPolicyLambdaExecution - .Properties - .PolicyDocument - .Statement[0] - .Resource = `arn:aws:logs:${this.options.region}:*:*`; - - // add custom iam role statements - if (this.serverless.service.provider.iamRoleStatements && - this.serverless.service.provider.iamRoleStatements instanceof Array) { - this.serverless.service.provider.compiledCloudFormationTemplate - .Resources - .IamPolicyLambdaExecution - .Properties - .PolicyDocument - .Statement = this.serverless.service.provider.compiledCloudFormationTemplate - .Resources - .IamPolicyLambdaExecution - .Properties - .PolicyDocument - .Statement.concat(this.serverless.service.provider.iamRoleStatements); - } + if (!artifactFilePath) { + throw new Error(`No artifact path is set for function: ${functionName}`); } - const functionTemplate = ` - { - "Type": "AWS::Lambda::Function", - "Properties": { - "Code": { - "S3Bucket": { "Ref": "ServerlessDeploymentBucket" }, - "S3Key": "S3Key" + if (this.serverless.service.package.deploymentBucket) { + newFunction.Properties.Code.S3Bucket = this.serverless.service.package.deploymentBucket; + } + + const s3Folder = this.serverless.service.package.artifactDirectoryName; + const s3FileName = artifactFilePath.split(path.sep).pop(); + newFunction.Properties.Code.S3Key = `${s3Folder}/${s3FileName}`; + + if (!functionObject.handler) { + const errorMessage = [ + `Missing "handler" property in function ${functionName}`, + ' Please make sure you point to the correct lambda handler.', + ' For example: handler.hello.', + ' Please check the docs for more info', + ].join(''); + throw new this.serverless.classes + .Error(errorMessage); + } + + const Handler = functionObject.handler; + const FunctionName = functionObject.name; + const MemorySize = Number(functionObject.memorySize) + || Number(this.serverless.service.provider.memorySize) + || 1024; + const Timeout = Number(functionObject.timeout) + || Number(this.serverless.service.provider.timeout) + || 6; + const Runtime = this.serverless.service.provider.runtime + || 'nodejs4.3'; + + newFunction.Properties.Handler = Handler; + newFunction.Properties.FunctionName = FunctionName; + newFunction.Properties.MemorySize = MemorySize; + newFunction.Properties.Timeout = Timeout; + newFunction.Properties.Runtime = Runtime; + + if (functionObject.description) { + newFunction.Properties.Description = functionObject.description; + } + + if (typeof this.serverless.service.provider.iamRoleARN === 'string') { + newFunction.Properties.Role = this.serverless.service.provider.iamRoleARN; + } else { + newFunction.Properties.Role = { 'Fn::GetAtt': ['IamRoleLambdaExecution', 'Arn'] }; + } + + if (!functionObject.vpc) functionObject.vpc = {}; + if (!this.serverless.service.provider.vpc) this.serverless.service.provider.vpc = {}; + + newFunction.Properties.VpcConfig = { + SecurityGroupIds: functionObject.vpc.securityGroupIds || + this.serverless.service.provider.vpc.securityGroupIds, + SubnetIds: functionObject.vpc.subnetIds || this.serverless.service.provider.vpc.subnetIds, + }; + + if (!newFunction.Properties.VpcConfig.SecurityGroupIds + || !newFunction.Properties.VpcConfig.SubnetIds) { + delete newFunction.Properties.VpcConfig; + } + + const normalizedFunctionName = functionName[0].toUpperCase() + functionName.substr(1); + const functionLogicalId = `${normalizedFunctionName}LambdaFunction`; + const newFunctionObject = { + [functionLogicalId]: newFunction, + }; + + _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, + newFunctionObject); + + // Add function to Outputs section + const newOutput = this.cfOutputDescriptionTemplate(); + newOutput.Value = { 'Fn::GetAtt': [functionLogicalId, 'Arn'] }; + + const newOutputObject = { + [`${functionLogicalId}Arn`]: newOutput, + }; + + _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Outputs, + newOutputObject); + } + + compileFunctions() { + this.serverless.service + .getAllFunctions() + .forEach((functionName) => this.compileFunction(functionName)); + } + + // Helper functions + cfLambdaFunctionTemplate() { + return { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: { + Ref: 'ServerlessDeploymentBucket', }, - "FunctionName": "FunctionName", - "Handler": "Handler", - "MemorySize": "MemorySize", - "Role": "Role", - "Runtime": "Runtime", - "Timeout": "Timeout" - } - } - `; + S3Key: 'S3Key', + }, + FunctionName: 'FunctionName', + Handler: 'Handler', + MemorySize: 'MemorySize', + Role: 'Role', + Runtime: 'Runtime', + Timeout: 'Timeout', + }, + }; + } - const outputTemplate = ` - { - "Description": "Lambda function info", - "Value": "Value" - } - `; - - this.serverless.service.getAllFunctions().forEach((functionName) => { - const newFunction = JSON.parse(functionTemplate); - const functionObject = this.serverless.service.getFunction(functionName); - - const artifactFilePath = this.serverless.service.package.individually ? - functionObject.artifact : - this.serverless.service.package.artifact; - - if (!artifactFilePath) { - throw new Error(`No artifact path is set for function: ${functionName}`); - } - - const s3Folder = this.serverless.service.package.artifactDirectoryName; - const s3FileName = artifactFilePath.split(path.sep).pop(); - newFunction.Properties.Code.S3Key = `${s3Folder}/${s3FileName}`; - - if (!functionObject.handler) { - const errorMessage = [ - `Missing "handler" property in function ${functionName}`, - ' Please make sure you point to the correct lambda handler.', - ' For example: handler.hello.', - ' Please check the docs for more info', - ].join(''); - throw new this.serverless.classes - .Error(errorMessage); - } - - const Handler = functionObject.handler; - const FunctionName = functionObject.name; - const MemorySize = Number(functionObject.memorySize) - || Number(this.serverless.service.provider.memorySize) - || 1024; - const Timeout = Number(functionObject.timeout) - || Number(this.serverless.service.provider.timeout) - || 6; - const Runtime = this.serverless.service.provider.runtime - || 'nodejs4.3'; - - newFunction.Properties.Handler = Handler; - newFunction.Properties.FunctionName = FunctionName; - newFunction.Properties.MemorySize = MemorySize; - newFunction.Properties.Timeout = Timeout; - newFunction.Properties.Runtime = Runtime; - - if (functionObject.description) { - newFunction.Properties.Description = functionObject.description; - } - - if (typeof this.serverless.service.provider.iamRoleARN === 'string') { - newFunction.Properties.Role = this.serverless.service.provider.iamRoleARN; - } else { - newFunction.Properties.Role = { 'Fn::GetAtt': ['IamRoleLambdaExecution', 'Arn'] }; - } - - if (!functionObject.vpc) functionObject.vpc = {}; - if (!this.serverless.service.provider.vpc) this.serverless.service.provider.vpc = {}; - - newFunction.Properties.VpcConfig = { - SecurityGroupIds: functionObject.vpc.securityGroupIds || - this.serverless.service.provider.vpc.securityGroupIds, - SubnetIds: functionObject.vpc.subnetIds || this.serverless.service.provider.vpc.subnetIds, - }; - - if (!newFunction.Properties.VpcConfig.SecurityGroupIds - || !newFunction.Properties.VpcConfig.SubnetIds) { - delete newFunction.Properties.VpcConfig; - } - - const normalizedFunctionName = functionName[0].toUpperCase() + functionName.substr(1); - const functionLogicalId = `${normalizedFunctionName}LambdaFunction`; - const newFunctionObject = { - [functionLogicalId]: newFunction, - }; - - _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, - newFunctionObject); - - // Add function to Outputs section - const newOutput = JSON.parse(outputTemplate); - newOutput.Value = { 'Fn::GetAtt': [functionLogicalId, 'Arn'] }; - - const newOutputObject = { - [`${functionLogicalId}Arn`]: newOutput, - }; - - _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Outputs, - newOutputObject); - }); + cfOutputDescriptionTemplate() { + return { + Description: 'Lambda function info', + Value: 'Value', + }; } } diff --git a/lib/plugins/aws/deploy/compile/functions/tests/index.js b/lib/plugins/aws/deploy/compile/functions/tests/index.js index f1c84526d..1f2ce8787 100644 --- a/lib/plugins/aws/deploy/compile/functions/tests/index.js +++ b/lib/plugins/aws/deploy/compile/functions/tests/index.js @@ -80,77 +80,6 @@ describe('AwsCompileFunctions', () => { .to.deep.equal(`${s3Folder}/${s3FileName}`); }); - it('should merge the IamRoleLambdaExecution template into the CloudFormation template', () => { - const IamRoleLambdaExecutionTemplate = awsCompileFunctions.serverless.utils.readFileSync( - path.join( - __dirname, - '..', - 'iam-role-lambda-execution-template.json' - ) - ); - - awsCompileFunctions.compileFunctions(); - - expect(awsCompileFunctions.serverless.service.provider.compiledCloudFormationTemplate - .Resources.IamRoleLambdaExecution - ).to.deep.equal(IamRoleLambdaExecutionTemplate.IamRoleLambdaExecution); - }); - - it('should merge IamPolicyLambdaExecution template into the CloudFormation template', () => { - awsCompileFunctions.compileFunctions(); - - // we check for the type here because a deep equality check will error out due to - // the updates which are made after the merge (they are tested in a separate test) - expect(awsCompileFunctions.serverless.service.provider.compiledCloudFormationTemplate - .Resources.IamPolicyLambdaExecution.Type - ).to.deep.equal('AWS::IAM::Policy'); - }); - - it('should update the necessary variables for the IamPolicyLambdaExecution', () => { - awsCompileFunctions.compileFunctions(); - - expect(awsCompileFunctions.serverless.service.provider.compiledCloudFormationTemplate - .Resources - .IamPolicyLambdaExecution - .Properties - .PolicyName - ).to.equal( - `${ - awsCompileFunctions.options.stage - }-${ - awsCompileFunctions.serverless.service.service - }-lambda` - ); - - expect(awsCompileFunctions.serverless.service.provider.compiledCloudFormationTemplate - .Resources - .IamPolicyLambdaExecution - .Properties - .PolicyDocument - .Statement[0] - .Resource - ).to.equal(`arn:aws:logs:${awsCompileFunctions.options.region}:*:*`); - }); - - it('should add custom IAM policy statements', () => { - awsCompileFunctions.serverless.service.provider.name = 'aws'; - awsCompileFunctions.serverless.service.provider.iamRoleStatements = [ - { - Effect: 'Allow', - Action: [ - 'something:SomethingElse', - ], - Resource: 'some:aws:arn:xxx:*:*', - }, - ]; - - awsCompileFunctions.compileFunctions(); - - expect(awsCompileFunctions.serverless.service.provider.compiledCloudFormationTemplate - .Resources.IamPolicyLambdaExecution.Properties.PolicyDocument.Statement[1] - ).to.deep.equal(awsCompileFunctions.serverless.service.provider.iamRoleStatements[0]); - }); - it('should add iamRoleARN', () => { awsCompileFunctions.serverless.service.provider.name = 'aws'; awsCompileFunctions.serverless.service.provider.iamRoleARN = 'some:aws:arn:xxx:*:*'; @@ -185,7 +114,7 @@ describe('AwsCompileFunctions', () => { name: 'new-service-dev-func', }, }; - const compliedFunction = { + const compiledFunction = { Type: 'AWS::Lambda::Function', Properties: { Code: { @@ -207,7 +136,7 @@ describe('AwsCompileFunctions', () => { expect( awsCompileFunctions.serverless.service.provider.compiledCloudFormationTemplate .Resources.FuncLambdaFunction - ).to.deep.equal(compliedFunction); + ).to.deep.equal(compiledFunction); }); it('should create a function resource with VPC config', () => { @@ -358,6 +287,55 @@ describe('AwsCompileFunctions', () => { ).to.deep.equal(compiledFunction); }); + it('should use a custom bucket if specified', () => { + const bucketName = 'com.serverless.deploys'; + + awsCompileFunctions.serverless.service.package.deploymentBucket = bucketName; + awsCompileFunctions.serverless.service.provider.runtime = 'python2.7'; + awsCompileFunctions.serverless.service.provider.memorySize = 128; + awsCompileFunctions.serverless.service.functions = { + func: { + handler: 'func.function.handler', + name: 'new-service-dev-func', + }, + }; + const compiledFunction = { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Key: `${awsCompileFunctions.serverless.service.package.artifactDirectoryName}/${ + awsCompileFunctions.serverless.service.package.artifact}`, + S3Bucket: bucketName, + }, + FunctionName: 'new-service-dev-func', + Handler: 'func.function.handler', + MemorySize: 128, + Role: { 'Fn::GetAtt': ['IamRoleLambdaExecution', 'Arn'] }, + Runtime: 'python2.7', + Timeout: 6, + }, + }; + const coreCloudFormationTemplate = awsCompileFunctions.serverless.utils.readFileSync( + path.join( + __dirname, + '..', + '..', + '..', + 'lib', + 'core-cloudformation-template.json' + ) + ); + awsCompileFunctions.serverless.service.provider + .compiledCloudFormationTemplate = coreCloudFormationTemplate; + + awsCompileFunctions.compileFunctions(); + + expect( + awsCompileFunctions.serverless.service.provider.compiledCloudFormationTemplate + .Resources.FuncLambdaFunction + ).to.deep.equal(compiledFunction); + }); + it('should include description if specified', () => { awsCompileFunctions.serverless.service.functions = { func: { diff --git a/lib/plugins/aws/deploy/index.js b/lib/plugins/aws/deploy/index.js index 7931b5de5..6cb1b4840 100644 --- a/lib/plugins/aws/deploy/index.js +++ b/lib/plugins/aws/deploy/index.js @@ -10,6 +10,7 @@ const setBucketName = require('./lib/setBucketName'); const cleanupS3Bucket = require('./lib/cleanupS3Bucket'); const uploadArtifacts = require('./lib/uploadArtifacts'); const updateStack = require('./lib/updateStack'); +const configureStack = require('./lib/configureStack'); const SDK = require('../'); @@ -30,12 +31,17 @@ class AwsDeploy { cleanupS3Bucket, uploadArtifacts, updateStack, - monitorStack + monitorStack, + configureStack ); this.hooks = { 'before:deploy:initialize': () => BbPromise.bind(this) - .then(this.validate), + .then(this.validate), + + 'deploy:initialize': () => BbPromise.bind(this) + .then(this.configureStack) + .then(this.mergeCustomProviderResources), 'deploy:setupProviderConfiguration': () => BbPromise.bind(this) .then(this.createStack) @@ -44,8 +50,6 @@ class AwsDeploy { 'before:deploy:compileFunctions': () => BbPromise.bind(this) .then(this.generateArtifactDirectoryName), - 'before:deploy:deploy': () => BbPromise.bind(this).then(this.mergeCustomProviderResources), - 'deploy:deploy': () => BbPromise.bind(this) .then(this.setBucketName) .then(this.cleanupS3Bucket) diff --git a/lib/plugins/aws/deploy/lib/cleanupS3Bucket.js b/lib/plugins/aws/deploy/lib/cleanupS3Bucket.js index 9a99f715c..ad92b5a37 100644 --- a/lib/plugins/aws/deploy/lib/cleanupS3Bucket.js +++ b/lib/plugins/aws/deploy/lib/cleanupS3Bucket.js @@ -7,19 +7,26 @@ module.exports = { getObjectsToRemove() { // 4 old ones + the one which will be uploaded after the cleanup = 5 const directoriesToKeepCount = 4; + const serviceStage = `${this.serverless.service.service}/${this.options.stage}`; return this.sdk.request('S3', 'listObjectsV2', - { Bucket: this.bucketName }, + { + Bucket: this.bucketName, + Prefix: `serverless/${serviceStage}`, + }, this.options.stage, this.options.region) .then((result) => { if (result.Contents.length) { let directories = []; + const regex = new RegExp( + `serverless/${serviceStage}/(.+-.+-.+-.+)` + ); // get the unique directory names result.Contents.forEach((obj) => { - const match = obj.Key.match(/(.+\-.+\-.+\-.+)\//); + const match = obj.Key.match(regex); if (match) { const directoryName = match[1]; diff --git a/lib/plugins/aws/deploy/lib/configureStack.js b/lib/plugins/aws/deploy/lib/configureStack.js new file mode 100644 index 000000000..2c96df68c --- /dev/null +++ b/lib/plugins/aws/deploy/lib/configureStack.js @@ -0,0 +1,109 @@ +'use strict'; + +const _ = require('lodash'); +const BbPromise = require('bluebird'); +const path = require('path'); + +module.exports = { + configureStack() { + this.serverless.service.provider + .compiledCloudFormationTemplate = this.serverless.utils.readFileSync( + path.join(this.serverless.config.serverlessPath, + 'plugins', + 'aws', + 'deploy', + 'lib', + 'core-cloudformation-template.json') + ); + + if (typeof this.serverless.service.provider.iamRoleARN !== 'string') { + // merge in the iamRoleLambdaTemplate + const iamRoleLambdaExecutionTemplate = this.serverless.utils.readFileSync( + path.join(this.serverless.config.serverlessPath, + 'plugins', + 'aws', + 'deploy', + 'lib', + 'iam-role-lambda-execution-template.json') + ); + + _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, + iamRoleLambdaExecutionTemplate); + + // merge in the iamPolicyLambdaTemplate + const iamPolicyLambdaExecutionTemplate = this.serverless.utils.readFileSync( + path.join(this.serverless.config.serverlessPath, + 'plugins', + 'aws', + 'deploy', + 'lib', + 'iam-policy-lambda-execution-template.json') + ); + + // set the necessary variables for the IamPolicyLambda + iamPolicyLambdaExecutionTemplate + .IamPolicyLambdaExecution + .Properties + .PolicyName = `${this.options.stage}-${this.serverless.service.service}-lambda`; + + iamPolicyLambdaExecutionTemplate + .IamPolicyLambdaExecution + .Properties + .PolicyDocument + .Statement[0] + .Resource = `arn:aws:logs:${this.options.region}:*:*`; + + _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, + iamPolicyLambdaExecutionTemplate); + + + // add custom iam role statements + if (this.serverless.service.provider.iamRoleStatements && + this.serverless.service.provider.iamRoleStatements instanceof Array) { + this.serverless.service.provider.compiledCloudFormationTemplate + .Resources + .IamPolicyLambdaExecution + .Properties + .PolicyDocument + .Statement = this.serverless.service.provider.compiledCloudFormationTemplate + .Resources + .IamPolicyLambdaExecution + .Properties + .PolicyDocument + .Statement.concat(this.serverless.service.provider.iamRoleStatements); + } + } + + const bucketName = this.serverless.service.provider.deploymentBucket; + + if (bucketName) { + return BbPromise.bind(this) + .then(() => this.validateS3BucketName(bucketName)) + .then(() => this.sdk.request('S3', + 'getBucketLocation', + { + Bucket: bucketName, + }, + this.options.stage, + this.options.region + )) + .then(result => { + if (result.LocationConstraint !== this.options.region) { + throw new this.serverless.classes.Error( + 'Deployment bucket is not in the same region as the lambda function' + ); + } + this.bucketName = bucketName; + this.serverless.service.package.deploymentBucket = bucketName; + this.serverless.service.provider.compiledCloudFormationTemplate + .Outputs.ServerlessDeploymentBucketName.Value = bucketName; + + delete this.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ServerlessDeploymentBucket; + }); + } + + return BbPromise.resolve(); + }, + +}; diff --git a/lib/plugins/aws/deploy/lib/createStack.js b/lib/plugins/aws/deploy/lib/createStack.js index 4ea7aed73..5a8b29da8 100644 --- a/lib/plugins/aws/deploy/lib/createStack.js +++ b/lib/plugins/aws/deploy/lib/createStack.js @@ -7,7 +7,7 @@ module.exports = { create() { this.serverless.cli.log('Creating Stack...'); const stackName = `${this.serverless.service.service}-${this.options.stage}`; - const coreCloudFormationTemplate = this.loadCoreCloudFormationTemplate(); + const params = { StackName: stackName, OnFailure: 'ROLLBACK', @@ -15,7 +15,8 @@ module.exports = { 'CAPABILITY_IAM', ], Parameters: [], - TemplateBody: JSON.stringify(coreCloudFormationTemplate), + TemplateBody: JSON.stringify(this.serverless.service.provider + .compiledCloudFormationTemplate), Tags: [{ Key: 'STAGE', Value: this.options.stage, @@ -41,8 +42,6 @@ module.exports = { ].join(''); throw new this.serverless.classes.Error(errorMessage); } - this.serverless.service.provider - .compiledCloudFormationTemplate = this.loadCoreCloudFormationTemplate(); return BbPromise.bind(this) // always write the template to disk, whether we are deploying or not @@ -70,17 +69,6 @@ module.exports = { }, // helper methods - loadCoreCloudFormationTemplate() { - return this.serverless.utils.readFileSync( - path.join(this.serverless.config.serverlessPath, - 'plugins', - 'aws', - 'deploy', - 'lib', - 'core-cloudformation-template.json') - ); - }, - writeCreateTemplateToDisk() { const cfTemplateFilePath = path.join(this.serverless.config.servicePath, '.serverless', 'cloudformation-template-create-stack.json'); diff --git a/lib/plugins/aws/deploy/lib/generateArtifactDirectoryName.js b/lib/plugins/aws/deploy/lib/generateArtifactDirectoryName.js index 05aa0740c..5656ead86 100644 --- a/lib/plugins/aws/deploy/lib/generateArtifactDirectoryName.js +++ b/lib/plugins/aws/deploy/lib/generateArtifactDirectoryName.js @@ -5,8 +5,10 @@ const BbPromise = require('bluebird'); module.exports = { generateArtifactDirectoryName() { const date = new Date(); + const serviceStage = `${this.serverless.service.service}/${this.options.stage}`; + const dateString = `${date.getTime().toString()}-${date.toISOString()}`; this.serverless.service.package - .artifactDirectoryName = `${date.getTime().toString()}-${date.toISOString()}`; + .artifactDirectoryName = `serverless/${serviceStage}/${dateString}`; return BbPromise.resolve(); }, diff --git a/lib/plugins/aws/deploy/compile/functions/iam-policy-lambda-execution-template.json b/lib/plugins/aws/deploy/lib/iam-policy-lambda-execution-template.json similarity index 100% rename from lib/plugins/aws/deploy/compile/functions/iam-policy-lambda-execution-template.json rename to lib/plugins/aws/deploy/lib/iam-policy-lambda-execution-template.json diff --git a/lib/plugins/aws/deploy/compile/functions/iam-role-lambda-execution-template.json b/lib/plugins/aws/deploy/lib/iam-role-lambda-execution-template.json similarity index 100% rename from lib/plugins/aws/deploy/compile/functions/iam-role-lambda-execution-template.json rename to lib/plugins/aws/deploy/lib/iam-role-lambda-execution-template.json diff --git a/lib/plugins/aws/deploy/lib/setBucketName.js b/lib/plugins/aws/deploy/lib/setBucketName.js index 858407d75..0b2995718 100644 --- a/lib/plugins/aws/deploy/lib/setBucketName.js +++ b/lib/plugins/aws/deploy/lib/setBucketName.js @@ -4,6 +4,10 @@ const BbPromise = require('bluebird'); module.exports = { setBucketName() { + if (this.bucketName) { + return BbPromise.resolve(this.bucketName); + } + if (this.options.noDeploy) { return BbPromise.resolve(); } diff --git a/lib/plugins/aws/deploy/lib/uploadArtifacts.js b/lib/plugins/aws/deploy/lib/uploadArtifacts.js index b88509bf6..5ce7bce69 100644 --- a/lib/plugins/aws/deploy/lib/uploadArtifacts.js +++ b/lib/plugins/aws/deploy/lib/uploadArtifacts.js @@ -16,6 +16,7 @@ module.exports = { Bucket: this.bucketName, Key: `${this.serverless.service.package.artifactDirectoryName}/${fileName}`, Body: body, + ContentType: 'application/json', }; return this.sdk.request('S3', @@ -38,6 +39,7 @@ module.exports = { Bucket: this.bucketName, Key: `${this.serverless.service.package.artifactDirectoryName}/${fileName}`, Body: body, + ContentType: 'application/zip', }; return this.sdk.request('S3', diff --git a/lib/plugins/aws/deploy/tests/all.js b/lib/plugins/aws/deploy/tests/all.js index bac4afbd8..8ee97df92 100644 --- a/lib/plugins/aws/deploy/tests/all.js +++ b/lib/plugins/aws/deploy/tests/all.js @@ -8,3 +8,4 @@ require('./cleanupS3Bucket'); require('./uploadArtifacts'); require('./updateStack'); require('./index'); +require('./configureStack'); diff --git a/lib/plugins/aws/deploy/tests/cleanupS3Bucket.js b/lib/plugins/aws/deploy/tests/cleanupS3Bucket.js index b9a8f1c0a..61097ea46 100644 --- a/lib/plugins/aws/deploy/tests/cleanupS3Bucket.js +++ b/lib/plugins/aws/deploy/tests/cleanupS3Bucket.js @@ -9,13 +9,16 @@ const Serverless = require('../../../../Serverless'); describe('cleanupS3Bucket', () => { let serverless; let awsDeploy; + let s3Key; beforeEach(() => { serverless = new Serverless(); + serverless.service.service = 'cleanupS3Bucket'; const options = { stage: 'dev', region: 'us-east-1', }; + s3Key = `serverless/${serverless.service.service}/${options.stage}`; awsDeploy = new AwsDeploy(serverless, options); awsDeploy.bucketName = 'deployment-bucket'; awsDeploy.serverless.cli = new serverless.classes.CLI(); @@ -35,6 +38,7 @@ describe('cleanupS3Bucket', () => { expect(listObjectsStub.args[0][0]).to.be.equal('S3'); expect(listObjectsStub.args[0][1]).to.be.equal('listObjectsV2'); expect(listObjectsStub.args[0][2].Bucket).to.be.equal(awsDeploy.bucketName); + expect(listObjectsStub.args[0][2].Prefix).to.be.equal(`${s3Key}`); expect(listObjectsStub.calledWith(awsDeploy.options.stage, awsDeploy.options.region)); awsDeploy.sdk.request.restore(); }); @@ -43,18 +47,18 @@ describe('cleanupS3Bucket', () => { it('should return all to be removed service objects (except the last 4)', () => { const serviceObjects = { Contents: [ - { Key: '151224711231-2016-08-18T15:42:00/artifact.zip' }, - { Key: '151224711231-2016-08-18T15:42:00/cloudformation.json' }, - { Key: '141264711231-2016-08-18T15:42:00/artifact.zip' }, - { Key: '141264711231-2016-08-18T15:42:00/cloudformation.json' }, - { Key: '141321321541-2016-08-18T11:23:02/artifact.zip' }, - { Key: '141321321541-2016-08-18T11:23:02/cloudformation.json' }, - { Key: '142003031341-2016-08-18T12:46:04/artifact.zip' }, - { Key: '142003031341-2016-08-18T12:46:04/cloudformation.json' }, - { Key: '113304333331-2016-08-18T13:40:06/artifact.zip' }, - { Key: '113304333331-2016-08-18T13:40:06/cloudformation.json' }, - { Key: '903940390431-2016-08-18T23:42:08/artifact.zip' }, - { Key: '903940390431-2016-08-18T23:42:08/cloudformation.json' }, + { Key: `${s3Key}/151224711231-2016-08-18T15:42:00/artifact.zip` }, + { Key: `${s3Key}/151224711231-2016-08-18T15:42:00/cloudformation.json` }, + { Key: `${s3Key}/141264711231-2016-08-18T15:42:00/artifact.zip` }, + { Key: `${s3Key}/141264711231-2016-08-18T15:42:00/cloudformation.json` }, + { Key: `${s3Key}/141321321541-2016-08-18T11:23:02/artifact.zip` }, + { Key: `${s3Key}/141321321541-2016-08-18T11:23:02/cloudformation.json` }, + { Key: `${s3Key}/142003031341-2016-08-18T12:46:04/artifact.zip` }, + { Key: `${s3Key}/142003031341-2016-08-18T12:46:04/cloudformation.json` }, + { Key: `${s3Key}/113304333331-2016-08-18T13:40:06/artifact.zip` }, + { Key: `${s3Key}/113304333331-2016-08-18T13:40:06/cloudformation.json` }, + { Key: `${s3Key}/903940390431-2016-08-18T23:42:08/artifact.zip` }, + { Key: `${s3Key}/903940390431-2016-08-18T23:42:08/cloudformation.json` }, ], }; @@ -63,25 +67,42 @@ describe('cleanupS3Bucket', () => { return awsDeploy.getObjectsToRemove().then((objectsToRemove) => { expect(objectsToRemove).to.not - .include({ Key: '141321321541-2016-08-18T11:23:02/artifact.zip' }); + .include( + { Key: `${s3Key}${s3Key}/141321321541-2016-08-18T11:23:02/artifact.zip` }); + expect(objectsToRemove).to.not - .include({ Key: '141321321541-2016-08-18T11:23:02/cloudformation.json' }); + .include( + { Key: `${s3Key}${s3Key}/141321321541-2016-08-18T11:23:02/cloudformation.json` }); + expect(objectsToRemove).to.not - .include({ Key: '142003031341-2016-08-18T12:46:04/artifact.zip' }); + .include( + { Key: `${s3Key}${s3Key}/142003031341-2016-08-18T12:46:04/artifact.zip` }); + expect(objectsToRemove).to.not - .include({ Key: '142003031341-2016-08-18T12:46:04/cloudformation.json' }); + .include( + { Key: `${s3Key}${s3Key}/142003031341-2016-08-18T12:46:04/cloudformation.json` }); + expect(objectsToRemove).to.not - .include({ Key: '151224711231-2016-08-18T15:42:00/artifact.zip' }); + .include( + { Key: `${s3Key}${s3Key}/151224711231-2016-08-18T15:42:00/artifact.zip` }); + expect(objectsToRemove).to.not - .include({ Key: '151224711231-2016-08-18T15:42:00/cloudformation.json' }); + .include( + { Key: `${s3Key}${s3Key}/151224711231-2016-08-18T15:42:00/cloudformation.json` }); + expect(objectsToRemove).to.not - .include({ Key: '903940390431-2016-08-18T23:42:08/artifact.zip' }); + .include( + { Key: `${s3Key}${s3Key}/903940390431-2016-08-18T23:42:08/artifact.zip` }); + expect(objectsToRemove).to.not - .include({ Key: '903940390431-2016-08-18T23:42:08/cloudformation.json' }); + .include( + { Key: `${s3Key}${s3Key}/903940390431-2016-08-18T23:42:08/cloudformation.json` }); + expect(listObjectsStub.calledOnce).to.be.equal(true); expect(listObjectsStub.args[0][0]).to.be.equal('S3'); expect(listObjectsStub.args[0][1]).to.be.equal('listObjectsV2'); expect(listObjectsStub.args[0][2].Bucket).to.be.equal(awsDeploy.bucketName); + expect(listObjectsStub.args[0][2].Prefix).to.be.equal(`${s3Key}`); expect(listObjectsStub.calledWith(awsDeploy.options.stage, awsDeploy.options.region)); awsDeploy.sdk.request.restore(); }); @@ -90,12 +111,12 @@ describe('cleanupS3Bucket', () => { it('should return an empty array if there are less than 4 directories available', () => { const serviceObjects = { Contents: [ - { Key: '151224711231-2016-08-18T15:42:00/artifact.zip' }, - { Key: '151224711231-2016-08-18T15:42:00/cloudformation.json' }, - { Key: '141264711231-2016-08-18T15:42:00/artifact.zip' }, - { Key: '141264711231-2016-08-18T15:42:00/cloudformation.json' }, - { Key: '141321321541-2016-08-18T11:23:02/artifact.zip' }, - { Key: '141321321541-2016-08-18T11:23:02/cloudformation.json' }, + { Key: `${s3Key}151224711231-2016-08-18T15:42:00/artifact.zip` }, + { Key: `${s3Key}151224711231-2016-08-18T15:42:00/cloudformation.json` }, + { Key: `${s3Key}141264711231-2016-08-18T15:42:00/artifact.zip` }, + { Key: `${s3Key}141264711231-2016-08-18T15:42:00/cloudformation.json` }, + { Key: `${s3Key}141321321541-2016-08-18T11:23:02/artifact.zip` }, + { Key: `${s3Key}141321321541-2016-08-18T11:23:02/cloudformation.json` }, ], }; @@ -108,6 +129,7 @@ describe('cleanupS3Bucket', () => { expect(listObjectsStub.args[0][0]).to.be.equal('S3'); expect(listObjectsStub.args[0][1]).to.be.equal('listObjectsV2'); expect(listObjectsStub.args[0][2].Bucket).to.be.equal(awsDeploy.bucketName); + expect(listObjectsStub.args[0][2].Prefix).to.be.equal(`${s3Key}`); expect(listObjectsStub.calledWith(awsDeploy.options.stage, awsDeploy.options.region)); awsDeploy.sdk.request.restore(); }); @@ -116,14 +138,14 @@ describe('cleanupS3Bucket', () => { it('should resolve if there are exactly 4 directories available', () => { const serviceObjects = { Contents: [ - { Key: '151224711231-2016-08-18T15:42:00/artifact.zip' }, - { Key: '151224711231-2016-08-18T15:42:00/cloudformation.json' }, - { Key: '141264711231-2016-08-18T15:42:00/artifact.zip' }, - { Key: '141264711231-2016-08-18T15:42:00/cloudformation.json' }, - { Key: '141321321541-2016-08-18T11:23:02/artifact.zip' }, - { Key: '141321321541-2016-08-18T11:23:02/cloudformation.json' }, - { Key: '142003031341-2016-08-18T12:46:04/artifact.zip' }, - { Key: '142003031341-2016-08-18T12:46:04/cloudformation.json' }, + { Key: `${s3Key}151224711231-2016-08-18T15:42:00/artifact.zip` }, + { Key: `${s3Key}151224711231-2016-08-18T15:42:00/cloudformation.json` }, + { Key: `${s3Key}141264711231-2016-08-18T15:42:00/artifact.zip` }, + { Key: `${s3Key}141264711231-2016-08-18T15:42:00/cloudformation.json` }, + { Key: `${s3Key}141321321541-2016-08-18T11:23:02/artifact.zip` }, + { Key: `${s3Key}141321321541-2016-08-18T11:23:02/cloudformation.json` }, + { Key: `${s3Key}142003031341-2016-08-18T12:46:04/artifact.zip` }, + { Key: `${s3Key}142003031341-2016-08-18T12:46:04/cloudformation.json` }, ], }; @@ -136,6 +158,7 @@ describe('cleanupS3Bucket', () => { expect(listObjectsStub.args[0][0]).to.be.equal('S3'); expect(listObjectsStub.args[0][1]).to.be.equal('listObjectsV2'); expect(listObjectsStub.args[0][2].Bucket).to.be.equal(awsDeploy.bucketName); + expect(listObjectsStub.args[0][2].Prefix).to.be.equal(`${s3Key}`); expect(listObjectsStub.calledWith(awsDeploy.options.stage, awsDeploy.options.region)); awsDeploy.sdk.request.restore(); }); @@ -159,10 +182,10 @@ describe('cleanupS3Bucket', () => { it('should remove all old service files from the S3 bucket if available', () => { const objectsToRemove = [ - { Key: '113304333331-2016-08-18T13:40:06/artifact.zip' }, - { Key: '113304333331-2016-08-18T13:40:06/cloudformation.json' }, - { Key: '141264711231-2016-08-18T15:42:00/artifact.zip' }, - { Key: '141264711231-2016-08-18T15:42:00/cloudformation.json' }, + { Key: `${s3Key}113304333331-2016-08-18T13:40:06/artifact.zip` }, + { Key: `${s3Key}113304333331-2016-08-18T13:40:06/cloudformation.json` }, + { Key: `${s3Key}141264711231-2016-08-18T15:42:00/artifact.zip` }, + { Key: `${s3Key}141264711231-2016-08-18T15:42:00/cloudformation.json` }, ]; return awsDeploy.removeObjects(objectsToRemove).then(() => { diff --git a/lib/plugins/aws/deploy/tests/configureStack.js b/lib/plugins/aws/deploy/tests/configureStack.js new file mode 100644 index 000000000..c5944e67d --- /dev/null +++ b/lib/plugins/aws/deploy/tests/configureStack.js @@ -0,0 +1,193 @@ +'use strict'; + +const sinon = require('sinon'); +const BbPromise = require('bluebird'); +const path = require('path'); +const expect = require('chai').expect; + +const Serverless = require('../../../../Serverless'); +const AwsSdk = require('../'); + +describe('#configureStack', () => { + let awsSdk; + let serverless; + + beforeEach(() => { + serverless = new Serverless(); + const options = { + stage: 'dev', + region: 'us-east-1', + }; + awsSdk = new AwsSdk(serverless, options); + awsSdk.serverless.cli = new serverless.classes.CLI(); + }); + + it('should validate the region for the given S3 bucket', () => { + const bucketName = 'com.serverless.deploys'; + + const getBucketLocationStub = sinon + .stub(awsSdk.sdk, 'request').returns( + BbPromise.resolve({ LocationConstraint: awsSdk.options.region }) + ); + + awsSdk.serverless.service.provider.deploymentBucket = bucketName; + return awsSdk.configureStack() + .then(() => { + expect(getBucketLocationStub.args[0][0]).to.equal('S3'); + expect(getBucketLocationStub.args[0][1]).to.equal('getBucketLocation'); + expect(getBucketLocationStub.args[0][2].Bucket).to.equal(bucketName); + }); + }); + + it('should reject an S3 bucket in the wrong region', () => { + const bucketName = 'com.serverless.deploys'; + + const createStackStub = sinon + .stub(awsSdk.sdk, 'request').returns( + BbPromise.resolve({ LocationConstraint: 'us-west-1' }) + ); + + awsSdk.serverless.service.provider.deploymentBucket = 'com.serverless.deploys'; + return awsSdk.configureStack() + .catch((err) => { + expect(createStackStub.args[0][0]).to.equal('S3'); + expect(createStackStub.args[0][1]).to.equal('getBucketLocation'); + expect(createStackStub.args[0][2].Bucket).to.equal(bucketName); + expect(err.message).to.contain('not in the same region'); + }) + .then(() => {}); + }); + + + it('should merge the IamRoleLambdaExecution template into the CloudFormation template', () => { + const IamRoleLambdaExecutionTemplate = awsSdk.serverless.utils.readFileSync( + path.join( + __dirname, + '..', + 'lib', + 'iam-role-lambda-execution-template.json' + ) + ); + + return awsSdk.configureStack() + .then(() => { + expect(awsSdk.serverless.service.provider.compiledCloudFormationTemplate + .Resources.IamRoleLambdaExecution + ).to.deep.equal(IamRoleLambdaExecutionTemplate.IamRoleLambdaExecution); + }); + }); + + it('should merge IamPolicyLambdaExecution template into the CloudFormation template', () => + awsSdk.configureStack() + .then(() => { + // we check for the type here because a deep equality check will error out due to + // the updates which are made after the merge (they are tested in a separate test) + expect(awsSdk.serverless.service.provider.compiledCloudFormationTemplate + .Resources.IamPolicyLambdaExecution.Type + ).to.deep.equal('AWS::IAM::Policy'); + }) + ); + + it('should update the necessary variables for the IamPolicyLambdaExecution', () => + awsSdk.configureStack() + .then(() => { + expect(awsSdk.serverless.service.provider.compiledCloudFormationTemplate + .Resources + .IamPolicyLambdaExecution + .Properties + .PolicyName + ).to.equal( + `${ + awsSdk.options.stage + }-${ + awsSdk.serverless.service.service + }-lambda` + ); + + expect(awsSdk.serverless.service.provider.compiledCloudFormationTemplate + .Resources + .IamPolicyLambdaExecution + .Properties + .PolicyDocument + .Statement[0] + .Resource + ).to.equal(`arn:aws:logs:${awsSdk.options.region}:*:*`); + }) + ); + + it('should add custom IAM policy statements', () => { + awsSdk.serverless.service.provider.name = 'aws'; + awsSdk.serverless.service.provider.iamRoleStatements = [ + { + Effect: 'Allow', + Action: [ + 'something:SomethingElse', + ], + Resource: 'some:aws:arn:xxx:*:*', + }, + ]; + + + return awsSdk.configureStack() + .then(() => { + expect(awsSdk.serverless.service.provider.compiledCloudFormationTemplate + .Resources.IamPolicyLambdaExecution.Properties.PolicyDocument.Statement[1] + ).to.deep.equal(awsSdk.serverless.service.provider.iamRoleStatements[0]); + }); + }); + + it('should use a custom bucket if specified', () => { + const bucketName = 'com.serverless.deploys'; + + awsSdk.serverless.service.provider.deploymentBucket = bucketName; + + const coreCloudFormationTemplate = awsSdk.serverless.utils.readFileSync( + path.join( + __dirname, + '..', + 'lib', + 'core-cloudformation-template.json' + ) + ); + awsSdk.serverless.service.provider + .compiledCloudFormationTemplate = coreCloudFormationTemplate; + + sinon + .stub(awsSdk.sdk, 'request') + .returns(BbPromise.resolve({ LocationConstraint: awsSdk.options.region })); + + return awsSdk.configureStack() + .then(() => { + expect( + awsSdk.serverless.service.provider.compiledCloudFormationTemplate + .Outputs.ServerlessDeploymentBucketName.Value + ).to.equal(bucketName); + // eslint-disable-next-line no-unused-expressions + expect( + awsSdk.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ServerlessDeploymentBucket + ).to.not.exist; + }); + }); + + it('should not add IamPolicyLambdaExecution', () => { + awsSdk.serverless.service.provider.iamRoleARN = 'some:aws:arn:xxx:*:*'; + + return awsSdk.configureStack() + .then(() => expect( + awsSdk.serverless.service.provider.compiledCloudFormationTemplate + .Resources.IamPolicyLambdaExecution + ).to.not.exist); + }); + + + it('should not add IamRole', () => { + awsSdk.serverless.service.provider.iamRoleARN = 'some:aws:arn:xxx:*:*'; + + return awsSdk.configureStack() + .then(() => expect( + awsSdk.serverless.service.provider.compiledCloudFormationTemplate + .Resources.IamRoleLambdaExecution + ).to.not.exist); + }); +}); diff --git a/lib/plugins/aws/deploy/tests/createStack.js b/lib/plugins/aws/deploy/tests/createStack.js index 8fcceebd3..9aae2e1bf 100644 --- a/lib/plugins/aws/deploy/tests/createStack.js +++ b/lib/plugins/aws/deploy/tests/createStack.js @@ -9,7 +9,6 @@ const Serverless = require('../../../../Serverless'); const testUtils = require('../../../../../tests/utils'); describe('createStack', () => { - let serverless; let awsDeploy; const tmpDirPath = testUtils.getTmpDirPath(); @@ -25,7 +24,7 @@ describe('createStack', () => { }; beforeEach(() => { - serverless = new Serverless(); + const serverless = new Serverless(); serverless.utils.writeFileSync(serverlessYmlPath, serverlessYml); serverless.config.servicePath = tmpDirPath; const options = { @@ -46,6 +45,9 @@ describe('createStack', () => { 'core-cloudformation-template.json') ); + awsDeploy.serverless.service.provider + .compiledCloudFormationTemplate = coreCloudFormationTemplate; + const createStackStub = sinon .stub(awsDeploy.sdk, 'request').returns(BbPromise.resolve()); @@ -60,7 +62,6 @@ describe('createStack', () => { .to.deep.equal([{ Key: 'STAGE', Value: awsDeploy.options.stage }]); expect(createStackStub.calledOnce).to.be.equal(true); expect(createStackStub.calledWith(awsDeploy.options.stage, awsDeploy.options.region)); - awsDeploy.sdk.request.restore(); }); }); }); @@ -69,7 +70,16 @@ describe('createStack', () => { it('should store the core CloudFormation template in the provider object', () => { sinon.stub(awsDeploy.sdk, 'request').returns(BbPromise.resolve()); - const coreCloudFormationTemplate = awsDeploy.loadCoreCloudFormationTemplate(); + const coreCloudFormationTemplate = awsDeploy.serverless.utils.readFileSync( + path.join(__dirname, + '..', + 'lib', + 'core-cloudformation-template.json') + ); + + awsDeploy.serverless.service.provider + .compiledCloudFormationTemplate = coreCloudFormationTemplate; + const writeCreateTemplateToDiskStub = sinon .stub(awsDeploy, 'writeCreateTemplateToDisk').returns(BbPromise.resolve()); @@ -77,8 +87,6 @@ describe('createStack', () => { expect(writeCreateTemplateToDiskStub.calledOnce).to.be.equal(true); expect(awsDeploy.serverless.service.provider.compiledCloudFormationTemplate) .to.deep.equal(coreCloudFormationTemplate); - - awsDeploy.sdk.request.restore(); }); }); @@ -90,8 +98,6 @@ describe('createStack', () => { return awsDeploy.createStack().then(() => { expect(createStub.called).to.be.equal(false); - awsDeploy.create.restore(); - awsDeploy.sdk.request.restore(); }); }); @@ -106,9 +112,6 @@ describe('createStack', () => { return awsDeploy.createStack().then(() => { expect(writeCreateTemplateToDiskStub.calledOnce).to.be.equal(true); expect(createStub.called).to.be.equal(false); - - awsDeploy.writeCreateTemplateToDisk.restore(); - awsDeploy.create.restore(); }); }); @@ -119,12 +122,10 @@ describe('createStack', () => { .stub(awsDeploy, 'writeCreateTemplateToDisk').returns(BbPromise.resolve()); sinon.stub(awsDeploy.sdk, 'request').returns(BbPromise.resolve()); - return awsDeploy.createStack().then(() => { + return awsDeploy.createStack().then((res) => { expect(writeCreateTemplateToDiskStub.calledOnce).to.be.equal(true); expect(awsDeploy.sdk.request.called).to.be.equal(true); - - awsDeploy.writeCreateTemplateToDisk.restore(); - awsDeploy.sdk.request.restore(); + expect(res).to.equal('alreadyCreated'); }); }); @@ -142,9 +143,6 @@ describe('createStack', () => { expect(createStub.called).to.be.equal(false); expect(e.name).to.be.equal('ServerlessError'); expect(e.message).to.be.equal(errorMock); - - awsDeploy.create.restore(); - awsDeploy.sdk.request.restore(); }); }); @@ -160,22 +158,10 @@ describe('createStack', () => { return awsDeploy.createStack().then(() => { expect(createStub.calledOnce).to.be.equal(true); - - awsDeploy.create.restore(); - awsDeploy.sdk.request.restore(); }); }); }); - describe('#loadCoreCloudFormationTemplate', () => { - it('should load the core CloudFormation template', () => { - const template = awsDeploy.loadCoreCloudFormationTemplate(); - - expect(template.Resources.ServerlessDeploymentBucket.Type) - .to.equal('AWS::S3::Bucket'); - }); - }); - describe('#writeCreateTemplateToDisk', () => { it('should write the compiled CloudFormation template into the .serverless directory', () => { awsDeploy.serverless.service.provider.compiledCloudFormationTemplate = { key: 'value' }; @@ -185,8 +171,10 @@ describe('createStack', () => { 'cloudformation-template-create-stack.json'); return awsDeploy.writeCreateTemplateToDisk().then(() => { - expect(serverless.utils.fileExistsSync(templatePath)).to.equal(true); - expect(serverless.utils.readFileSync(templatePath)).to.deep.equal({ key: 'value' }); + expect(awsDeploy.serverless.utils.fileExistsSync(templatePath)).to.equal(true); + expect(awsDeploy.serverless.utils.readFileSync(templatePath)).to.deep.equal( + { key: 'value' } + ); }); }); }); diff --git a/lib/plugins/aws/deploy/tests/index.js b/lib/plugins/aws/deploy/tests/index.js index 4aec5da03..d5f3de785 100644 --- a/lib/plugins/aws/deploy/tests/index.js +++ b/lib/plugins/aws/deploy/tests/index.js @@ -7,27 +7,32 @@ const BbPromise = require('bluebird'); const sinon = require('sinon'); describe('AwsDeploy', () => { - const serverless = new Serverless(); - const options = { - stage: 'dev', - region: 'us-east-1', - }; - const awsDeploy = new AwsDeploy(serverless, options); - awsDeploy.serverless.cli = new serverless.classes.CLI(); + let awsDeploy; + beforeEach(() => { + const serverless = new Serverless(); + const options = { + stage: 'dev', + region: 'us-east-1', + }; + + awsDeploy = new AwsDeploy(serverless, options); + awsDeploy.serverless.cli = new serverless.classes.CLI(); + }); describe('#constructor()', () => { it('should have hooks', () => expect(awsDeploy.hooks).to.be.not.empty); it('should set the provider variable to "aws"', () => expect(awsDeploy.provider) .to.equal('aws')); + }); + describe('hooks', () => { it('should run "before:deploy:initialize" hook promise chain in order', () => { const validateStub = sinon .stub(awsDeploy, 'validate').returns(BbPromise.resolve()); return awsDeploy.hooks['before:deploy:initialize']().then(() => { expect(validateStub.calledOnce).to.be.equal(true); - awsDeploy.validate.restore(); }); }); @@ -40,8 +45,6 @@ describe('AwsDeploy', () => { return awsDeploy.hooks['deploy:setupProviderConfiguration']().then(() => { expect(createStackStub.calledOnce).to.be.equal(true); expect(monitorStackStub.calledOnce).to.be.equal(true); - awsDeploy.createStack.restore(); - awsDeploy.monitorStack.restore(); }); }); @@ -51,17 +54,19 @@ describe('AwsDeploy', () => { return awsDeploy.hooks['before:deploy:compileFunctions']().then(() => { expect(generateArtifactDirectoryNameStub.calledOnce).to.be.equal(true); - awsDeploy.generateArtifactDirectoryName.restore(); }); }); - it('should run "before:deploy:deploy" promise chain in order', () => { + it('should run "deploy:initialize" promise chain in order', () => { + const configureStackStub = sinon + .stub(awsDeploy, 'configureStack').returns(BbPromise.resolve()); + const mergeCustomProviderResourcesStub = sinon .stub(awsDeploy, 'mergeCustomProviderResources').returns(BbPromise.resolve()); - return awsDeploy.hooks['before:deploy:deploy']().then(() => { + return awsDeploy.hooks['deploy:initialize']().then(() => { + expect(configureStackStub.calledOnce).to.be.equal(true); expect(mergeCustomProviderResourcesStub.calledOnce).to.be.equal(true); - awsDeploy.mergeCustomProviderResources.restore(); }); }); @@ -87,12 +92,20 @@ describe('AwsDeploy', () => { .to.be.equal(true); expect(monitorStackStub.calledAfter(updateStackStub)) .to.be.equal(true); + }); + }); + + it('should notify about noDeploy', () => { + sinon.stub(awsDeploy, 'setBucketName').returns(BbPromise.resolve()); + sinon.stub(awsDeploy, 'cleanupS3Bucket').returns(BbPromise.resolve()); + sinon.stub(awsDeploy, 'uploadArtifacts').returns(BbPromise.resolve()); + sinon.stub(awsDeploy, 'updateStack').returns(BbPromise.resolve()); + sinon.stub(awsDeploy, 'monitorStack').returns(BbPromise.resolve()); + sinon.stub(awsDeploy.serverless.cli, 'log').returns(); + awsDeploy.options.noDeploy = true; + + return awsDeploy.hooks['deploy:deploy']().then(() => { - awsDeploy.setBucketName.restore(); - awsDeploy.cleanupS3Bucket.restore(); - awsDeploy.uploadArtifacts.restore(); - awsDeploy.updateStack.restore(); - awsDeploy.monitorStack.restore(); }); }); }); diff --git a/lib/plugins/aws/deploy/tests/setBucketName.js b/lib/plugins/aws/deploy/tests/setBucketName.js index c7249bd50..6652d3497 100644 --- a/lib/plugins/aws/deploy/tests/setBucketName.js +++ b/lib/plugins/aws/deploy/tests/setBucketName.js @@ -42,4 +42,12 @@ describe('#setBucketName()', () => { awsDeploy.sdk.getServerlessDeploymentBucketName.restore(); }); }); + + it('should resolve if the bucketName is already set', () => { + const bucketName = 'someBucket'; + awsDeploy.bucketName = bucketName; + return awsDeploy.setBucketName() + .then(() => expect(getServerlessDeploymentBucketNameStub.calledOnce).to.be.false) + .then(() => expect(awsDeploy.bucketName).to.equal(bucketName)); + }); }); diff --git a/lib/plugins/aws/index.js b/lib/plugins/aws/index.js index f7bea2fcd..88e7b6646 100644 --- a/lib/plugins/aws/index.js +++ b/lib/plugins/aws/index.js @@ -69,7 +69,7 @@ class SDK { ].join(''); err.message = errorMessage; } - reject(new this.serverless.classes.Error(err.message)); + reject(new this.serverless.classes.Error(err.message, err.statusCode)); } else { resolve(data); } @@ -99,9 +99,7 @@ class SDK { }, stage, region - ).then((result) => - result.StackResourceDetail.PhysicalResourceId - ); + ).then((result) => result.StackResourceDetail.PhysicalResourceId); } getStackName(stage) { diff --git a/lib/plugins/aws/lib/validate.js b/lib/plugins/aws/lib/validate.js index 6b4cb9de9..038754327 100644 --- a/lib/plugins/aws/lib/validate.js +++ b/lib/plugins/aws/lib/validate.js @@ -18,4 +18,45 @@ module.exports = { return BbPromise.resolve(); }, + + /** + * Retrieved 9/27/2016 from http://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html + * Bucket names must be at least 3 and no more than 63 characters long. + * Bucket names must be a series of one or more labels. + * Adjacent labels are separated by a single period (.). + * Bucket names can contain lowercase letters, numbers, and hyphens. + * Each label must start and end with a lowercase letter or a number. + * Bucket names must not be formatted as an IP address (e.g., 192.168.5.4). + * @param bucketName + */ + validateS3BucketName(bucketName) { + return BbPromise.resolve() + .then(() => { + let error; + if (!bucketName) { + error = 'Bucket name cannot be undefined or empty'; + } else if (bucketName.length < 3) { + error = `Bucket name is shorter than 3 characters. ${bucketName}`; + } else if (bucketName.length > 63) { + error = `Bucket name is longer than 63 characters. ${bucketName}`; + } else if (/^[^a-z0-9]/.test(bucketName)) { + error = `Bucket name must start with a letter or number. ${bucketName}`; + } else if (/[^a-z0-9]$/.test(bucketName)) { + error = `Bucket name must end with a letter or number. ${bucketName}`; + } else if (/[A-Z]/.test(bucketName)) { + error = `Bucket name cannot contain uppercase letters. ${bucketName}`; + } else if (!/^[a-z0-9][a-z.0-9-]+[a-z0-9]$/.test(bucketName)) { + error = `Bucket name contains invalid characters, [a-z.0-9-] ${bucketName}`; + } else if (/\.{2,}/.test(bucketName)) { + error = `Bucket name cannot contain consecutive periods (.) ${bucketName}`; + } else if (/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(bucketName)) { + error = `Bucket name cannot look like an IPv4 address. ${bucketName}`; + } + + if (error) { + throw new this.serverless.classes.Error(error); + } + return true; + }); + }, }; diff --git a/lib/plugins/aws/remove/lib/bucket.js b/lib/plugins/aws/remove/lib/bucket.js index 8fb9a18b3..b0037c9e0 100644 --- a/lib/plugins/aws/remove/lib/bucket.js +++ b/lib/plugins/aws/remove/lib/bucket.js @@ -14,8 +14,10 @@ module.exports = { this.objectsInBucket = []; this.serverless.cli.log('Getting all objects in S3 bucket...'); + const serviceStage = `${this.serverless.service.service}/${this.options.stage}`; return this.sdk.request('S3', 'listObjectsV2', { Bucket: this.bucketName, + Prefix: `serverless/${serviceStage}`, }, this.options.stage, this.options.region).then((result) => { if (result) { result.Contents.forEach((object) => { diff --git a/lib/plugins/aws/tests/index.js b/lib/plugins/aws/tests/index.js index be422ae36..e236bfb6f 100644 --- a/lib/plugins/aws/tests/index.js +++ b/lib/plugins/aws/tests/index.js @@ -7,38 +7,43 @@ const Serverless = require('../../../Serverless'); const AwsSdk = require('../'); describe('AWS SDK', () => { + let awsSdk; + let serverless; + + beforeEach(() => { + serverless = new Serverless(); + const options = { + stage: 'dev', + region: 'us-east-1', + }; + awsSdk = new AwsSdk(serverless, options); + awsSdk.serverless.cli = new serverless.classes.CLI(); + }); + describe('#constructor()', () => { it('should set AWS instance', () => { - const serverless = new Serverless(); - const awsSdk = new AwsSdk(serverless); - expect(typeof awsSdk.sdk).to.not.equal('undefined'); }); it('should set Serverless instance', () => { - const serverless = new Serverless(); - const awsSdk = new AwsSdk(serverless); - expect(typeof awsSdk.serverless).to.not.equal('undefined'); }); it('should set AWS proxy', () => { - const serverless = new Serverless(); process.env.proxy = 'http://a.b.c.d:n'; - const awsSdk = new AwsSdk(serverless); + const newAwsSdk = new AwsSdk(serverless); - expect(typeof awsSdk.sdk.config.httpOptions.agent).to.not.equal('undefined'); + expect(typeof newAwsSdk.sdk.config.httpOptions.agent).to.not.equal('undefined'); // clear env delete process.env.proxy; }); it('should set AWS timeout', () => { - const serverless = new Serverless(); process.env.AWS_CLIENT_TIMEOUT = '120000'; - const awsSdk = new AwsSdk(serverless); + const newAwsSdk = new AwsSdk(serverless); - expect(typeof awsSdk.sdk.config.httpOptions.timeout).to.not.equal('undefined'); + expect(typeof newAwsSdk.sdk.config.httpOptions.timeout).to.not.equal('undefined'); // clear env delete process.env.AWS_CLIENT_TIMEOUT; @@ -59,12 +64,10 @@ describe('AWS SDK', () => { }; } } - const serverless = new Serverless(); - const awsSdk = new AwsSdk(serverless); awsSdk.sdk = { S3: FakeS3, }; - serverless.service.environment = { + awsSdk.serverless.service.environment = { vars: {}, stages: { dev: { @@ -75,42 +78,127 @@ describe('AWS SDK', () => { }, }, }; - serverless.service.environment.stages.dev.regions['us-east-1'] = { - vars: {}, - }; + return awsSdk.request('S3', 'putObject', {}, 'dev', 'us-east-1').then(data => { expect(data.called).to.equal(true); }); }); + + it('should retry if error code is 429', function (done) { + this.timeout(10000); + let first = true; + const error = { + statusCode: 429, + message: 'Testing retry', + }; + class FakeS3 { + constructor(credentials) { + this.credentials = credentials; + } + + error() { + return { + send(cb) { + if (first) { + cb(error); + } else { + cb(undefined, {}); + } + first = false; + }, + }; + } + } + awsSdk.sdk = { + S3: FakeS3, + }; + awsSdk.request('S3', 'error', {}, 'dev', 'us-east-1') + .then(data => { + // eslint-disable-next-line no-unused-expressions + expect(data).to.exist; + // eslint-disable-next-line no-unused-expressions + expect(first).to.be.false; + done(); + }) + .catch(done); + }); + + it('should reject errors', (done) => { + const error = { + statusCode: 500, + message: 'Some error message', + }; + class FakeS3 { + constructor(credentials) { + this.credentials = credentials; + } + + error() { + return { + send(cb) { + cb(error); + }, + }; + } + } + awsSdk.sdk = { + S3: FakeS3, + }; + awsSdk.request('S3', 'error', {}, 'dev', 'us-east-1') + .then(() => done('Should not succeed')) + .catch(() => done()); + }); + + it('should return ref to docs for missing credentials', (done) => { + const error = { + statusCode: 403, + message: 'Missing credentials in config', + }; + class FakeS3 { + constructor(credentials) { + this.credentials = credentials; + } + + error() { + return { + send(cb) { + cb(error); + }, + }; + } + } + awsSdk.sdk = { + S3: FakeS3, + }; + awsSdk.request('S3', 'error', {}, 'dev', 'us-east-1') + .then(() => done('Should not succeed')) + .catch((err) => { + expect(err.message).to.contain('https://git.io/viZAC'); + done(); + }) + .catch(done); + }); }); describe('#getCredentials()', () => { it('should set region for credentials', () => { - const serverless = new Serverless(); - const awsSdk = new AwsSdk(serverless); const credentials = awsSdk.getCredentials('testregion'); expect(credentials.region).to.equal('testregion'); }); it('should get credentials from provider', () => { - const serverless = new Serverless(); - const awsSdk = new AwsSdk(serverless); serverless.service.provider.profile = 'notDefault'; const credentials = awsSdk.getCredentials(); expect(credentials.credentials.profile).to.equal('notDefault'); }); it('should not set credentials if empty profile is set', () => { - const serverless = new Serverless(); - const awsSdk = new AwsSdk(serverless); serverless.service.provider.profile = ''; const credentials = awsSdk.getCredentials('testregion'); expect(credentials).to.eql({ region: 'testregion' }); }); it('should not set credentials if profile is not set', () => { - const serverless = new Serverless(); - const awsSdk = new AwsSdk(serverless); serverless.service.provider.profile = undefined; const credentials = awsSdk.getCredentials('testregion'); expect(credentials).to.eql({ region: 'testregion' }); @@ -119,8 +207,6 @@ describe('AWS SDK', () => { describe('#getServerlessDeploymentBucketName', () => { it('should return the name of the serverless deployment bucket', () => { - const serverless = new Serverless(); - const awsSdk = new AwsSdk(serverless); const options = { stage: 'dev', region: 'us-east-1', @@ -152,9 +238,7 @@ describe('AWS SDK', () => { describe('#getStackName', () => { it('should return the stack name', () => { - const serverless = new Serverless(); serverless.service.service = 'myservice'; - const awsSdk = new AwsSdk(serverless); expect(awsSdk.getStackName('dev')).to.equal('myservice-dev'); }); diff --git a/lib/plugins/aws/tests/validate.js b/lib/plugins/aws/tests/validate.js index fd7ec5094..1489cbdc0 100644 --- a/lib/plugins/aws/tests/validate.js +++ b/lib/plugins/aws/tests/validate.js @@ -4,7 +4,7 @@ const expect = require('chai').expect; const validate = require('../lib/validate'); const Serverless = require('../../../Serverless'); -describe('#validate()', () => { +describe('#validate', () => { const serverless = new Serverless(); const awsPlugin = {}; @@ -20,50 +20,143 @@ describe('#validate()', () => { Object.assign(awsPlugin, validate); }); - it('should succeed if inside service (servicePath defined)', () => { - expect(() => awsPlugin.validate()).to.not.throw(Error); - }); + describe('#validate()', () => { + it('should succeed if inside service (servicePath defined)', () => { + expect(() => awsPlugin.validate()).to.not.throw(Error); + }); - it('should throw error if not inside service (servicePath not defined)', () => { - awsPlugin.serverless.config.servicePath = false; - expect(() => awsPlugin.validate()).to.throw(Error); - }); + it('should throw error if not inside service (servicePath not defined)', () => { + awsPlugin.serverless.config.servicePath = false; + expect(() => awsPlugin.validate()).to.throw(Error); + }); - // NOTE: starting here, test order is important + // NOTE: starting here, test order is important - it('should default to "dev" if stage is not provided', () => { - awsPlugin.options.stage = false; - return awsPlugin.validate().then(() => { - expect(awsPlugin.options.stage).to.equal('dev'); + it('should default to "dev" if stage is not provided', () => { + awsPlugin.options.stage = false; + return awsPlugin.validate().then(() => { + expect(awsPlugin.options.stage).to.equal('dev'); + }); + }); + + it('should use the service.defaults stage if present', () => { + awsPlugin.options.stage = false; + awsPlugin.serverless.service.defaults = { + stage: 'some-stage', + }; + + return awsPlugin.validate().then(() => { + expect(awsPlugin.options.stage).to.equal('some-stage'); + }); + }); + + it('should default to "us-east-1" region if region is not provided', () => { + awsPlugin.options.region = false; + return awsPlugin.validate().then(() => { + expect(awsPlugin.options.region).to.equal('us-east-1'); + }); + }); + + it('should use the service.defaults region if present', () => { + awsPlugin.options.region = false; + awsPlugin.serverless.service.defaults = { + region: 'some-region', + }; + + return awsPlugin.validate().then(() => { + expect(awsPlugin.options.region).to.equal('some-region'); + }); }); }); - it('should use the service.defaults stage if present', () => { - awsPlugin.options.stage = false; - awsPlugin.serverless.service.defaults = { - stage: 'some-stage', - }; + describe('#validateS3BucketName()', () => { + it('should reject an ip address as a name', () => + awsPlugin.validateS3BucketName('127.0.0.1') + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('cannot look like an IPv4 address')) + ); - return awsPlugin.validate().then(() => { - expect(awsPlugin.options.stage).to.equal('some-stage'); + it('should reject names that are too long', () => { + const bucketName = Array.from({ length: 64 }, () => 'j').join(''); + return awsPlugin.validateS3BucketName(bucketName) + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('longer than 63 characters')); }); - }); - it('should default to "us-east-1" region if region is not provided', () => { - awsPlugin.options.region = false; - return awsPlugin.validate().then(() => { - expect(awsPlugin.options.region).to.equal('us-east-1'); - }); - }); + it('should reject names that are too short', () => + awsPlugin.validateS3BucketName('12') + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('shorter than 3 characters')) + ); - it('should use the service.defaults region if present', () => { - awsPlugin.options.region = false; - awsPlugin.serverless.service.defaults = { - region: 'some-region', - }; + it('should reject names that contain invalid characters', () => + awsPlugin.validateS3BucketName('this has b@d characters') + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('contains invalid characters')) + ); - return awsPlugin.validate().then(() => { - expect(awsPlugin.options.region).to.equal('some-region'); - }); + it('should reject names that have consecutive periods', () => + awsPlugin.validateS3BucketName('otherwise..valid.name') + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('cannot contain consecutive periods')) + ); + + it('should reject names that start with a dash', () => + awsPlugin.validateS3BucketName('-invalid.name') + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('start with a letter or number')) + ); + + it('should reject names that start with a period', () => + awsPlugin.validateS3BucketName('.invalid.name') + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('start with a letter or number')) + ); + + it('should reject names that end with a dash', () => + awsPlugin.validateS3BucketName('invalid.name-') + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('end with a letter or number')) + ); + + it('should reject names that end with a period', () => + awsPlugin.validateS3BucketName('invalid.name.') + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('end with a letter or number')) + ); + + it('should reject names that contain uppercase letters', () => + awsPlugin.validateS3BucketName('otherwise.Valid.name') + .then(() => { + throw new Error('Should not get here'); + }) + .catch(err => expect(err.message).to.contain('cannot contain uppercase letters')) + ); + + it('should accept valid names', () => + awsPlugin.validateS3BucketName('1.this.is.valid.2') + .then(() => awsPlugin.validateS3BucketName('another.valid.name')) + .then(() => awsPlugin.validateS3BucketName('1-2-3')) + .then(() => awsPlugin.validateS3BucketName('123')) + .then(() => awsPlugin.validateS3BucketName('should.be.allowed-to-mix')) + ); }); });