From 32138549e29fdc060fa57ab328a522ec86c7c99a Mon Sep 17 00:00:00 2001 From: Frank Schmid Date: Thu, 6 Jul 2017 11:24:01 +0200 Subject: [PATCH] Support S3 server side encryption (#65) * Added support for CFN role. * Support S3 server side encryption * Added unit test for updateAlias. Fixed alias tag in create fallback. * Added tests for uploadAliasArtifacts --- lib/updateAliasStack.js | 10 +- lib/uploadAliasArtifacts.js | 28 ++- test/updateAliasStack.test.js | 339 ++++++++++++++++++++++++++++++ test/uploadAliasArtifacts.test.js | 177 ++++++++++++++++ 4 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 test/updateAliasStack.test.js create mode 100644 test/uploadAliasArtifacts.test.js diff --git a/lib/updateAliasStack.js b/lib/updateAliasStack.js index f9ead72..a14fabc 100644 --- a/lib/updateAliasStack.js +++ b/lib/updateAliasStack.js @@ -13,7 +13,7 @@ module.exports = { this._serverless.cli.log('Creating alias stack...'); const stackName = `${this._provider.naming.getStackName()}-${this._alias}`; - let stackTags = { STAGE: this._options.stage }; + let stackTags = { STAGE: this._options.stage, ALIAS: this._alias }; const templateUrl = `https://s3.amazonaws.com/${ this.bucketName }/${ @@ -36,6 +36,10 @@ module.exports = { Tags: _.map(_.keys(stackTags), key => ({ Key: key, Value: stackTags[key] })), }; + if (this.serverless.service.provider.cfnRole) { + params.RoleARN = this.serverless.service.provider.cfnRole; + } + return this._provider.request('CloudFormation', 'createStack', params, @@ -71,6 +75,10 @@ module.exports = { Tags: _.map(_.keys(stackTags), key => ({ Key: key, Value: stackTags[key] })), }; + if (this.serverless.service.provider.cfnRole) { + params.RoleARN = this.serverless.service.provider.cfnRole; + } + // Policy must have at least one statement, otherwise no updates would be possible at all if (this._serverless.service.provider.stackPolicy && this._serverless.service.provider.stackPolicy.length) { diff --git a/lib/uploadAliasArtifacts.js b/lib/uploadAliasArtifacts.js index c2e7cc3..c8e346c 100644 --- a/lib/uploadAliasArtifacts.js +++ b/lib/uploadAliasArtifacts.js @@ -1,6 +1,7 @@ 'use strict'; const BbPromise = require('bluebird'); +const _ = require('lodash'); module.exports = { uploadAliasCloudFormationFile() { @@ -10,13 +11,18 @@ module.exports = { const fileName = 'compiled-cloudformation-template-alias.json'; - const params = { + let params = { Bucket: this.bucketName, Key: `${this.serverless.service.package.artifactDirectoryName}/${fileName}`, Body: body, ContentType: 'application/json', }; + const deploymentBucketObject = this.serverless.service.provider.deploymentBucketObject; + if (deploymentBucketObject) { + params = setServersideEncryptionOptions(params, deploymentBucketObject); + } + return this.provider.request('S3', 'putObject', params, @@ -34,3 +40,23 @@ module.exports = { }, }; + +function setServersideEncryptionOptions(putParams, deploymentBucketOptions) { + const encryptionFields = { + 'serverSideEncryption': 'ServerSideEncryption', + 'sseCustomerAlgorithm': 'SSECustomerAlgorithm', + 'sseCustomerKey': 'SSECustomerKey', + 'sseCustomerKeyMD5': 'SSECustomerKeyMD5', + 'sseKMSKeyId': 'SSEKMSKeyId', + }; + + const params = putParams; + + _.forOwn(encryptionFields, (value, field) => { + if (deploymentBucketOptions[field]) { + params[value] = deploymentBucketOptions[field]; + } + }); + + return params; +} diff --git a/test/updateAliasStack.test.js b/test/updateAliasStack.test.js new file mode 100644 index 0000000..2424de5 --- /dev/null +++ b/test/updateAliasStack.test.js @@ -0,0 +1,339 @@ +'use strict'; +/** + * Unit tests for createAliasStack.. + */ + +const getInstalledPath = require('get-installed-path'); +const BbPromise = require('bluebird'); +const chai = require('chai'); +const sinon = require('sinon'); +const path = require('path'); +const AWSAlias = require('../index'); + +const serverlessPath = getInstalledPath.sync('serverless', { local: true }); +const AwsProvider = require(`${serverlessPath}/lib/plugins/aws/provider/awsProvider`); +const Serverless = require(`${serverlessPath}/lib/Serverless`); + +chai.use(require('chai-as-promised')); +chai.use(require('sinon-chai')); +const expect = chai.expect; + +describe('updateAliasStack', () => { + let serverless; + let options; + let awsAlias; + // Sinon and stubs for SLS CF access + let sandbox; + let providerRequestStub; + let monitorStackStub; + let logStub; + + before(() => { + sandbox = sinon.sandbox.create(); + }); + + beforeEach(() => { + serverless = new Serverless(); + options = { + alias: 'myAlias', + stage: 'myStage', + region: 'us-east-1', + }; + serverless.setProvider('aws', new AwsProvider(serverless, options)); + serverless.cli = new serverless.classes.CLI(serverless); + serverless.service.service = 'testService'; + serverless.service.provider.compiledCloudFormationAliasTemplate = {}; + serverless.service.package.artifactDirectoryName = 'myDirectory'; + awsAlias = new AWSAlias(serverless, options); + providerRequestStub = sandbox.stub(awsAlias._provider, 'request'); + monitorStackStub = sandbox.stub(awsAlias, 'monitorStack'); + logStub = sandbox.stub(serverless.cli, 'log'); + awsAlias.bucketName = 'myBucket'; + + logStub.returns(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('#createAliasFallback()', () => { + it('Should call CF with correct default parameters', () => { + const expectedCFData = { + StackName: 'testService-myStage-myAlias', + Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], + OnFailure: 'DELETE', + Parameters: [], + Tags: [ + { Key: 'STAGE', Value: 'myStage' }, + { Key: 'ALIAS', Value: 'myAlias' } + ], + TemplateURL: 'https://s3.amazonaws.com/myBucket/myDirectory/compiled-cloudformation-template-alias.json', + }; + const requestResult = { + status: 'ok' + }; + providerRequestStub.returns(BbPromise.resolve(requestResult)); + monitorStackStub.returns(BbPromise.resolve()); + + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = {}; + + return expect(awsAlias.validate()).to.be.fulfilled + .then(() => expect(awsAlias.createAliasFallback()).to.be.fulfilled) + .then(() => BbPromise.all([ + expect(providerRequestStub).to.have.been.calledOnce, + expect(monitorStackStub).to.have.been.calledOnce, + expect(providerRequestStub).to.have.been + .calledWithExactly('CloudFormation', 'createStack', expectedCFData, 'myStage', 'us-east-1'), + expect(monitorStackStub).to.have.been + .calledWithExactly('create', requestResult) + ])); + }); + + it('should set stack tags', () => { + const expectedCFData = { + StackName: 'testService-myStage-myAlias', + Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], + OnFailure: 'DELETE', + Parameters: [], + Tags: [ + { Key: 'STAGE', Value: 'myStage' }, + { Key: 'ALIAS', Value: 'myAlias' }, + { Key: 'tag1', Value: 'application'}, + { Key: 'tag2', Value: 'component' } + ], + TemplateURL: 'https://s3.amazonaws.com/myBucket/myDirectory/compiled-cloudformation-template-alias.json', + }; + providerRequestStub.returns(BbPromise.resolve("done")); + monitorStackStub.returns(BbPromise.resolve()); + + serverless.service.provider.stackTags = { + tag1: 'application', + tag2: 'component' + }; + + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = {}; + + return expect(awsAlias.validate()).to.be.fulfilled + .then(() => expect(awsAlias.createAliasFallback()).to.be.fulfilled) + .then(() => BbPromise.all([ + expect(providerRequestStub).to.have.been.calledOnce, + expect(providerRequestStub).to.have.been + .calledWithExactly('CloudFormation', 'createStack', expectedCFData, 'myStage', 'us-east-1'), + ])); + }); + + it('should use CFN role', () => { + const expectedCFData = { + StackName: 'testService-myStage-myAlias', + Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], + OnFailure: 'DELETE', + Parameters: [], + Tags: [ + { Key: 'STAGE', Value: 'myStage' }, + { Key: 'ALIAS', Value: 'myAlias' }, + ], + RoleARN: 'myRole', + TemplateURL: 'https://s3.amazonaws.com/myBucket/myDirectory/compiled-cloudformation-template-alias.json', + }; + providerRequestStub.returns(BbPromise.resolve("done")); + monitorStackStub.returns(BbPromise.resolve()); + + serverless.service.provider.cfnRole = 'myRole'; + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = {}; + + return expect(awsAlias.validate()).to.be.fulfilled + .then(() => expect(awsAlias.createAliasFallback()).to.be.fulfilled) + .then(() => BbPromise.all([ + expect(providerRequestStub).to.have.been.calledOnce, + expect(providerRequestStub).to.have.been + .calledWithExactly('CloudFormation', 'createStack', expectedCFData, 'myStage', 'us-east-1'), + ])); + }); + + it('should reject with CF error', () => { + providerRequestStub.returns(BbPromise.reject(new Error('CF failed'))); + monitorStackStub.returns(BbPromise.resolve()); + + return expect(awsAlias.createAliasFallback()).to.be.rejectedWith('CF failed') + .then(() => expect(providerRequestStub).to.have.been.calledOnce); + }); + }); + + describe('#updateAlias()', () => { + it('Should call CF with correct default parameters', () => { + const expectedCFData = { + StackName: 'testService-myStage-myAlias', + Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], + Parameters: [], + Tags: [ + { Key: 'STAGE', Value: 'myStage' }, + { Key: 'ALIAS', Value: 'myAlias' } + ], + TemplateURL: 'https://s3.amazonaws.com/myBucket/myDirectory/compiled-cloudformation-template-alias.json', + }; + const requestResult = { + status: 'ok' + }; + providerRequestStub.returns(BbPromise.resolve(requestResult)); + monitorStackStub.returns(BbPromise.resolve()); + + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = {}; + + return expect(awsAlias.validate()).to.be.fulfilled + .then(() => expect(awsAlias.updateAlias()).to.be.fulfilled) + .then(() => BbPromise.all([ + expect(providerRequestStub).to.have.been.calledOnce, + expect(monitorStackStub).to.have.been.calledOnce, + expect(providerRequestStub).to.have.been + .calledWithExactly('CloudFormation', 'updateStack', expectedCFData, 'myStage', 'us-east-1'), + expect(monitorStackStub).to.have.been + .calledWithExactly('update', requestResult) + ])); + }); + + it('should set stack tags', () => { + const expectedCFData = { + StackName: 'testService-myStage-myAlias', + Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], + Parameters: [], + Tags: [ + { Key: 'STAGE', Value: 'myStage' }, + { Key: 'ALIAS', Value: 'myAlias' }, + { Key: 'tag1', Value: 'application'}, + { Key: 'tag2', Value: 'component' } + ], + TemplateURL: 'https://s3.amazonaws.com/myBucket/myDirectory/compiled-cloudformation-template-alias.json', + }; + providerRequestStub.returns(BbPromise.resolve("done")); + monitorStackStub.returns(BbPromise.resolve()); + + serverless.service.provider.stackTags = { + tag1: 'application', + tag2: 'component' + }; + + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = {}; + + return expect(awsAlias.validate()).to.be.fulfilled + .then(() => expect(awsAlias.updateAlias()).to.be.fulfilled) + .then(() => BbPromise.all([ + expect(providerRequestStub).to.have.been.calledOnce, + expect(providerRequestStub).to.have.been + .calledWithExactly('CloudFormation', 'updateStack', expectedCFData, 'myStage', 'us-east-1'), + ])); + }); + + it('should use CFN role', () => { + const expectedCFData = { + StackName: 'testService-myStage-myAlias', + Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], + Parameters: [], + Tags: [ + { Key: 'STAGE', Value: 'myStage' }, + { Key: 'ALIAS', Value: 'myAlias' }, + ], + RoleARN: 'myRole', + TemplateURL: 'https://s3.amazonaws.com/myBucket/myDirectory/compiled-cloudformation-template-alias.json', + }; + providerRequestStub.returns(BbPromise.resolve("done")); + monitorStackStub.returns(BbPromise.resolve()); + + serverless.service.provider.cfnRole = 'myRole'; + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = {}; + + return expect(awsAlias.validate()).to.be.fulfilled + .then(() => expect(awsAlias.updateAlias()).to.be.fulfilled) + .then(() => BbPromise.all([ + expect(providerRequestStub).to.have.been.calledOnce, + expect(providerRequestStub).to.have.been + .calledWithExactly('CloudFormation', 'updateStack', expectedCFData, 'myStage', 'us-east-1'), + ])); + }); + + it('should reject with CF error', () => { + providerRequestStub.returns(BbPromise.reject(new Error('CF failed'))); + monitorStackStub.returns(BbPromise.resolve()); + + return expect(awsAlias.updateAlias()).to.be.rejectedWith('CF failed') + .then(() => expect(providerRequestStub).to.have.been.calledOnce); + }); + + it('should resolve in case no updates are performed', () => { + providerRequestStub.returns(BbPromise.resolve("done")); + monitorStackStub.returns(BbPromise.reject(new Error('No updates are to be performed.'))); + + return expect(awsAlias.updateAlias()).to.be.fulfilled + .then(() => expect(providerRequestStub).to.have.been.calledOnce); + }); + }); + + describe('#updateAliasStack()', () => { + let writeAliasUpdateTemplateToDiskStub; + let createAliasFallbackStub; + let updateAliasStub; + + beforeEach(() => { + writeAliasUpdateTemplateToDiskStub = sandbox.stub(awsAlias, 'writeAliasUpdateTemplateToDisk'); + createAliasFallbackStub = sandbox.stub(awsAlias, 'createAliasFallback'); + updateAliasStub = sandbox.stub(awsAlias, 'updateAlias'); + + writeAliasUpdateTemplateToDiskStub.returns(BbPromise.resolve()); + createAliasFallbackStub.returns(BbPromise.resolve()); + updateAliasStub.returns(BbPromise.resolve()); + }); + + it('should write template and update stack', () => { + return expect(awsAlias.updateAliasStack()).to.be.fulfilled + .then(() => BbPromise.all([ + expect(writeAliasUpdateTemplateToDiskStub).to.have.been.calledOnce, + expect(updateAliasStub).to.have.been.calledOnce, + ])); + }); + + it('should create alias if createLater has been set', () => { + awsAlias._createLater = true; + + return expect(awsAlias.updateAliasStack()).to.be.fulfilled + .then(() => BbPromise.all([ + expect(writeAliasUpdateTemplateToDiskStub).to.have.been.calledOnce, + expect(createAliasFallbackStub).to.have.been.calledOnce, + expect(updateAliasStub).to.not.have.been.called, + ])); + }); + + it('should resolve with noDeploy', () => { + awsAlias._options.noDeploy = true; + + return expect(awsAlias.updateAliasStack()).to.be.fulfilled + .then(() => BbPromise.all([ + expect(writeAliasUpdateTemplateToDiskStub).to.have.been.calledOnce, + expect(createAliasFallbackStub).to.not.have.been.called, + expect(updateAliasStub).to.not.have.been.called, + ])); + }); + }); + + describe('#writeAliasUpdateTemplateToDisk()', () => { + let writeFileSyncStub; + + beforeEach(() => { + writeFileSyncStub = sandbox.stub(serverless.utils, 'writeFileSync'); + }); + + it('should write the alias template', () => { + const expectedPath = path.join('path-to-service', '.serverless', 'cloudformation-template-update-alias-stack.json'); + const template = {}; + writeFileSyncStub.returns(); + + serverless.config.servicePath = 'path-to-service'; + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = template; + + return expect(awsAlias.writeAliasUpdateTemplateToDisk()).to.be.fulfilled + .then(() => BbPromise.all([ + expect(writeFileSyncStub).to.have.been.calledOnce, + expect(writeFileSyncStub).to.have.been.calledWithExactly(expectedPath, template) + ])); + }); + }); +}); diff --git a/test/uploadAliasArtifacts.test.js b/test/uploadAliasArtifacts.test.js new file mode 100644 index 0000000..987f9a8 --- /dev/null +++ b/test/uploadAliasArtifacts.test.js @@ -0,0 +1,177 @@ +'use strict'; +/** + * Unit tests for createAliasStack.. + */ + +const getInstalledPath = require('get-installed-path'); +const BbPromise = require('bluebird'); +const chai = require('chai'); +const sinon = require('sinon'); +const AWSAlias = require('../index'); + +const serverlessPath = getInstalledPath.sync('serverless', { local: true }); +const AwsProvider = require(`${serverlessPath}/lib/plugins/aws/provider/awsProvider`); +const Serverless = require(`${serverlessPath}/lib/Serverless`); + +chai.use(require('chai-as-promised')); +chai.use(require('sinon-chai')); +const expect = chai.expect; + +describe('uploadAliasArtifacts', () => { + let serverless; + let options; + let awsAlias; + // Sinon and stubs for SLS CF access + let sandbox; + let providerRequestStub; + let logStub; + + before(() => { + sandbox = sinon.sandbox.create(); + }); + + beforeEach(() => { + serverless = new Serverless(); + options = { + alias: 'myAlias', + stage: 'myStage', + region: 'us-east-1', + }; + serverless.setProvider('aws', new AwsProvider(serverless, options)); + serverless.cli = new serverless.classes.CLI(serverless); + serverless.service.service = 'testService'; + serverless.service.provider.compiledCloudFormationAliasTemplate = {}; + serverless.service.package.artifactDirectoryName = 'myDirectory'; + awsAlias = new AWSAlias(serverless, options); + providerRequestStub = sandbox.stub(awsAlias._provider, 'request'); + logStub = sandbox.stub(serverless.cli, 'log'); + awsAlias.bucketName = 'myBucket'; + + logStub.returns(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('#uploadAliasCloudFormationFile()', () => { + it('Should call S3 putObject with correct default parameters', () => { + const expectedData = { + Bucket: 'myBucket', + Key: 'myDirectory/compiled-cloudformation-template-alias.json', + Body: '{}', + ContentType: 'application/json', + }; + const requestResult = { + status: 'ok' + }; + providerRequestStub.returns(BbPromise.resolve(requestResult)); + + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = {}; + + return expect(awsAlias.uploadAliasCloudFormationFile()).to.be.fulfilled + .then(() => BbPromise.all([ + expect(providerRequestStub).to.have.been.calledOnce, + expect(providerRequestStub).to.have.been + .calledWithExactly('S3', 'putObject', expectedData, 'myStage', 'us-east-1'), + ])); + }); + + it('should use SSE configuration and set all supported keys', () => { + const expectedData = { + Bucket: 'myBucket', + Key: 'myDirectory/compiled-cloudformation-template-alias.json', + Body: '{}', + ContentType: 'application/json', + ServerSideEncryption: true, + SSEKMSKeyId: 'keyID', + SSECustomerAlgorithm: 'AES', + SSECustomerKey: 'key', + SSECustomerKeyMD5: 'md5', + }; + const requestResult = { + status: 'ok' + }; + providerRequestStub.returns(BbPromise.resolve(requestResult)); + + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = {}; + serverless.service.provider.deploymentBucketObject = { + serverSideEncryption: true, + sseKMSKeyId: 'keyID', + sseCustomerAlgorithm: 'AES', + sseCustomerKey: 'key', + sseCustomerKeyMD5: 'md5', + }; + + return expect(awsAlias.uploadAliasCloudFormationFile()).to.be.fulfilled + .then(() => BbPromise.all([ + expect(providerRequestStub).to.have.been.calledOnce, + expect(providerRequestStub).to.have.been + .calledWithExactly('S3', 'putObject', expectedData, 'myStage', 'us-east-1'), + ])); + }); + + it('should use SSE configuration and ignore all unsupported keys', () => { + const expectedData = { + Bucket: 'myBucket', + Key: 'myDirectory/compiled-cloudformation-template-alias.json', + Body: '{}', + ContentType: 'application/json', + ServerSideEncryption: true, + SSEKMSKeyId: 'keyID', + }; + const requestResult = { + status: 'ok' + }; + providerRequestStub.returns(BbPromise.resolve(requestResult)); + + serverless.service.provider.compiledCloudFormationAliasCreateTemplate = {}; + serverless.service.provider.deploymentBucketObject = { + serverSideEncryption: true, + sseKMSKeyId: 'keyID', + sseCustomAlgorithm: 'AES', + unknown: 'key', + invalid: 'md5', + }; + + return expect(awsAlias.uploadAliasCloudFormationFile()).to.be.fulfilled + .then(() => BbPromise.all([ + expect(providerRequestStub).to.have.been.calledOnce, + expect(providerRequestStub).to.have.been + .calledWithExactly('S3', 'putObject', expectedData, 'myStage', 'us-east-1'), + ])); + }); + + it('should reject with S3 error', () => { + providerRequestStub.returns(BbPromise.reject(new Error('Failed'))); + + return expect(awsAlias.uploadAliasCloudFormationFile()).to.be.rejectedWith('Failed') + .then(() => expect(providerRequestStub).to.have.been.calledOnce); + }); + }); + + describe('#uploadAliasArtifacts()', () => { + let uploadAliasCloudFormationFileStub; + + beforeEach(() => { + uploadAliasCloudFormationFileStub = sandbox.stub(awsAlias, 'uploadAliasCloudFormationFile'); + uploadAliasCloudFormationFileStub.returns(BbPromise.resolve()); + }); + + it('should call uploadAliasCloudFormationFile', () => { + return expect(awsAlias.uploadAliasArtifacts()).to.be.fulfilled + .then(() => BbPromise.all([ + expect(uploadAliasCloudFormationFileStub).to.have.been.calledOnce, + ])); + }); + + it('should resolve with noDeploy', () => { + awsAlias._options.noDeploy = true; + + return expect(awsAlias.uploadAliasArtifacts()).to.be.fulfilled + .then(() => BbPromise.all([ + expect(uploadAliasCloudFormationFileStub).to.not.have.been.called, + ])); + }); + }); +});