Add rollback function support

This commit is contained in:
Philipp Muens 2017-05-08 14:48:13 +02:00
parent d9bc8efb3f
commit 69f945650c
9 changed files with 672 additions and 38 deletions

View File

@ -23,6 +23,7 @@
"./aws/metrics/awsMetrics.js",
"./aws/remove/index.js",
"./aws/rollback/index.js",
"./aws/rollbackFunction/index.js",
"./aws/package/compile/functions/index.js",
"./aws/package/compile/events/schedule/index.js",
"./aws/package/compile/events/s3/index.js",

View File

@ -19,10 +19,15 @@ class AwsDeployList {
this.hooks = {
'before:deploy:list:log': () => BbPromise.bind(this)
.then(this.validate),
.then(this.validate),
'before:deploy:list:functions:log': () => BbPromise.bind(this)
.then(this.validate),
'deploy:list:log': () => BbPromise.bind(this)
.then(this.setBucketName)
.then(this.listDeployments),
'deploy:list:functions:log': () => BbPromise.bind(this)
.then(this.listFunctions),
};
}
@ -61,6 +66,77 @@ class AwsDeployList {
return BbPromise.resolve();
});
}
// list all functions and their versions
listFunctions() {
return BbPromise.resolve().bind(this)
.then(this.getFunctions)
.then(this.getFunctionVersions)
.then(this.displayFunctions);
}
getFunctions() {
const params = {
MaxItems: 200,
};
return this.provider.request('Lambda',
'listFunctions',
params,
this.options.stage,
this.options.region)
.then((result) => {
const allFuncs = result.Functions;
const serviceName = `${this.serverless.service.service}-${this.options.stage}`;
const regex = new RegExp(serviceName);
const serviceFuncs = allFuncs.filter((func) => func.FunctionName.match(regex));
return BbPromise.resolve(serviceFuncs);
});
}
getFunctionVersions(funcs) {
const requestPromises = [];
funcs.forEach((func) => {
const params = {
FunctionName: func.FunctionName,
MaxItems: 5,
};
const request = this.provider.request('Lambda',
'listVersionsByFunction',
params,
this.options.stage,
this.options.region);
requestPromises.push(request);
});
return BbPromise.all(requestPromises);
}
displayFunctions(funcs) {
this.serverless.cli.log('Listing functions and their versions:');
this.serverless.cli.log('-------------');
funcs.forEach((func) => {
let message = '';
let name = func.Versions[0].FunctionName;
name = name.replace(`${this.serverless.service.service}-`, '');
name = name.replace(`${this.options.stage}-`, '');
message += `${name}: `;
const versions = func.Versions.map((funcEntry) => funcEntry.Version);
message += versions.join(', ');
this.serverless.cli.log(message);
});
return BbPromise.resolve();
}
}
module.exports = AwsDeployList;

View File

@ -8,7 +8,7 @@ const Serverless = require('../../../Serverless');
describe('AwsDeployList', () => {
let serverless;
let awsDeploy;
let awsDeployList;
let s3Key;
beforeEach(() => {
@ -20,9 +20,9 @@ describe('AwsDeployList', () => {
region: 'us-east-1',
};
s3Key = `serverless/${serverless.service.service}/${options.stage}`;
awsDeploy = new AwsDeployList(serverless, options);
awsDeploy.bucketName = 'deployment-bucket';
awsDeploy.serverless.cli = {
awsDeployList = new AwsDeployList(serverless, options);
awsDeployList.bucketName = 'deployment-bucket';
awsDeployList.serverless.cli = {
log: sinon.spy(),
};
});
@ -33,29 +33,29 @@ describe('AwsDeployList', () => {
Contents: [],
};
const listObjectsStub = sinon
.stub(awsDeploy.provider, 'request').resolves(s3Response);
.stub(awsDeployList.provider, 'request').resolves(s3Response);
return awsDeploy.listDeployments().then(() => {
return awsDeployList.listDeployments().then(() => {
expect(listObjectsStub.calledOnce).to.be.equal(true);
expect(listObjectsStub.calledWithExactly(
'S3',
'listObjectsV2',
{
Bucket: awsDeploy.bucketName,
Bucket: awsDeployList.bucketName,
Prefix: `${s3Key}`,
},
awsDeploy.options.stage,
awsDeploy.options.region
awsDeployList.options.stage,
awsDeployList.options.region
)).to.be.equal(true);
const infoText = 'Couldn\'t find any existing deployments.';
expect(awsDeploy.serverless.cli.log.calledWithExactly(infoText)).to.be.equal(true);
expect(awsDeployList.serverless.cli.log.calledWithExactly(infoText)).to.be.equal(true);
const verifyText = 'Please verify that stage and region are correct.';
expect(awsDeploy.serverless.cli.log.calledWithExactly(verifyText)).to.be.equal(true);
awsDeploy.provider.request.restore();
expect(awsDeployList.serverless.cli.log.calledWithExactly(verifyText)).to.be.equal(true);
awsDeployList.provider.request.restore();
});
});
it('should all available deployments', () => {
it('should display all available deployments', () => {
const s3Response = {
Contents: [
{ Key: `${s3Key}/113304333331-2016-08-18T13:40:06/artifact.zip` },
@ -66,31 +66,170 @@ describe('AwsDeployList', () => {
};
const listObjectsStub = sinon
.stub(awsDeploy.provider, 'request').resolves(s3Response);
.stub(awsDeployList.provider, 'request').resolves(s3Response);
return awsDeploy.listDeployments().then(() => {
return awsDeployList.listDeployments().then(() => {
expect(listObjectsStub.calledOnce).to.be.equal(true);
expect(listObjectsStub.calledWithExactly(
'S3',
'listObjectsV2',
{
Bucket: awsDeploy.bucketName,
Bucket: awsDeployList.bucketName,
Prefix: `${s3Key}`,
},
awsDeploy.options.stage,
awsDeploy.options.region
awsDeployList.options.stage,
awsDeployList.options.region
)).to.be.equal(true);
const infoText = 'Listing deployments:';
expect(awsDeploy.serverless.cli.log.calledWithExactly(infoText)).to.be.equal(true);
expect(awsDeployList.serverless.cli.log.calledWithExactly(infoText)).to.be.equal(true);
const timestampOne = 'Timestamp: 113304333331';
const datetimeOne = 'Datetime: 2016-08-18T13:40:06';
expect(awsDeploy.serverless.cli.log.calledWithExactly(timestampOne)).to.be.equal(true);
expect(awsDeploy.serverless.cli.log.calledWithExactly(datetimeOne)).to.be.equal(true);
expect(awsDeployList.serverless.cli.log.calledWithExactly(timestampOne)).to.be.equal(true);
expect(awsDeployList.serverless.cli.log.calledWithExactly(datetimeOne)).to.be.equal(true);
const timestampTow = 'Timestamp: 903940390431';
const datetimeTwo = 'Datetime: 2016-08-18T23:42:08';
expect(awsDeploy.serverless.cli.log.calledWithExactly(timestampTow)).to.be.equal(true);
expect(awsDeploy.serverless.cli.log.calledWithExactly(datetimeTwo)).to.be.equal(true);
awsDeploy.provider.request.restore();
expect(awsDeployList.serverless.cli.log.calledWithExactly(timestampTow)).to.be.equal(true);
expect(awsDeployList.serverless.cli.log.calledWithExactly(datetimeTwo)).to.be.equal(true);
awsDeployList.provider.request.restore();
});
});
});
describe('#listFunctions()', () => {
let getFunctionsStub;
let getFunctionVersionsStub;
let displayFunctionsStub;
beforeEach(() => {
getFunctionsStub = sinon.stub(awsDeployList, 'getFunctions').resolves();
getFunctionVersionsStub = sinon.stub(awsDeployList, 'getFunctionVersions').resolves();
displayFunctionsStub = sinon.stub(awsDeployList, 'displayFunctions').resolves();
});
afterEach(() => {
awsDeployList.getFunctions.restore();
awsDeployList.getFunctionVersions.restore();
awsDeployList.displayFunctions.restore();
});
it('should run promise chain in order', () => awsDeployList
.listFunctions().then(() => {
expect(getFunctionsStub.calledOnce).to.equal(true);
expect(getFunctionVersionsStub.calledAfter(getFunctionsStub)).to.equal(true);
expect(displayFunctionsStub.calledAfter(getFunctionVersionsStub)).to.equal(true);
})
);
});
describe('#getFunctions()', () => {
let listFunctionsStub;
beforeEach(() => {
listFunctionsStub = sinon
.stub(awsDeployList.provider, 'request')
.resolves({
Functions: [
{ FunctionName: 'listDeployments-dev-func1' },
{ FunctionName: 'listDeployments-dev-func2' },
{ FunctionName: 'unrelatedService-dev-func3' },
{ FunctionName: 'unrelatedService-dev-func4' },
],
});
});
afterEach(() => {
awsDeployList.provider.request.restore();
});
it('should get all functions and filter out the service related ones', () => {
const expectedResult = [
{ FunctionName: 'listDeployments-dev-func1' },
{ FunctionName: 'listDeployments-dev-func2' },
];
return awsDeployList.getFunctions().then((result) => {
expect(listFunctionsStub.calledOnce).to.equal(true);
expect(listFunctionsStub.calledWithExactly(
'Lambda',
'listFunctions',
{
MaxItems: 200,
},
awsDeployList.options.stage,
awsDeployList.options.region
)).to.equal(true);
expect(result).to.deep.equal(expectedResult);
});
});
});
describe('#getFunctionVersions()', () => {
let listVersionsByFunctionStub;
beforeEach(() => {
listVersionsByFunctionStub = sinon
.stub(awsDeployList.provider, 'request')
.resolves({
Versions: [
{ FunctionName: 'listDeployments-dev-func', Version: '$LATEST' },
],
});
});
afterEach(() => {
awsDeployList.provider.request.restore();
});
it('should return the versions for the provided functions', () => {
const funcs = [
{ FunctionName: 'listDeployments-dev-func1' },
{ FunctionName: 'listDeployments-dev-func2' },
];
return awsDeployList.getFunctionVersions(funcs).then((result) => {
const expectedResult = [
{
Versions: [
{ FunctionName: 'listDeployments-dev-func', Version: '$LATEST' },
],
},
{
Versions: [
{ FunctionName: 'listDeployments-dev-func', Version: '$LATEST' },
],
},
];
expect(listVersionsByFunctionStub.calledTwice).to.equal(true);
expect(result).to.deep.equal(expectedResult);
});
});
});
describe('#displayFunctions()', () => {
const funcs = [
{
Versions: [
{ FunctionName: 'listDeployments-dev-func-1', Version: '$LATEST' },
],
},
{
Versions: [
{ FunctionName: 'listDeployments-dev-func-2', Version: '$LATEST' },
{ FunctionName: 'listDeployments-dev-func-2', Version: '4711' },
],
},
];
it('should display all the functions in the service together with their versions', () => {
const log = awsDeployList.serverless.cli.log;
return awsDeployList.displayFunctions(funcs).then(() => {
expect(log.calledWithExactly('Listing functions and their versions:')).to.be.equal(true);
expect(log.calledWithExactly('-------------')).to.be.equal(true);
expect(log.calledWithExactly('func-1: $LATEST')).to.be.equal(true);
expect(log.calledWithExactly('func-2: $LATEST, 4711')).to.be.equal(true);
});
});
});

View File

@ -0,0 +1,123 @@
'use strict';
const BbPromise = require('bluebird');
const validate = require('../lib/validate');
const fetch = require('node-fetch');
class AwsRollbackFunction {
constructor(serverless, options) {
this.serverless = serverless;
this.options = options || {};
this.provider = this.serverless.getProvider('aws');
Object.assign(this, validate);
this.commands = {
rollback: {
commands: {
function: {
usage: 'Rollback the function to a specific version',
lifecycleEvents: [
'rollback',
],
options: {
function: {
usage: 'Name of the function',
shortcut: 'f',
required: true,
},
version: {
usage: 'Version of the function',
shortcut: 'v',
required: true,
},
stage: {
usage: 'Stage of the function',
shortcut: 's',
},
region: {
usage: 'Region of the function',
shortcut: 'r',
},
},
},
},
},
};
this.hooks = {
'rollback:function:rollback': () => BbPromise.bind(this)
.then(this.validate)
.then(this.getFunctionToBeRestored)
.then(this.fetchFunctionCode)
.then(this.restoreFunction),
};
}
getFunctionToBeRestored() {
const funcName = this.options.function;
let funcVersion = this.options.version;
// versions need to be string so that AWS understands it
funcVersion = String(this.options.version);
this.serverless.cli.log(`Rolling back function "${funcName}" to version "${funcVersion}"...`);
const funcObj = this.serverless.service.getFunction(funcName);
const params = {
FunctionName: funcObj.name,
Qualifier: funcVersion,
};
return this.provider.request(
'Lambda',
'getFunction',
params,
this.options.stage, this.options.region
)
.then((func) => func)
.catch((error) => {
if (error.message.match(/not found/)) {
const errorMessage = [
`Function "${funcName}" with version "${funcVersion}" not found.`,
` Please check if you've deployed "${funcName}"`,
` and version "${funcVersion}" is available for this function.`,
' Please check the docs for more info.',
].join('');
throw new Error(errorMessage);
}
throw new Error(error.message);
});
}
fetchFunctionCode(func) {
const codeUrl = func.Code.Location;
return fetch(codeUrl).then((response) => response.buffer());
}
restoreFunction(zipBuffer) {
const funcName = this.options.function;
this.serverless.cli.log('Restoring function...');
const funcObj = this.serverless.service.getFunction(funcName);
const params = {
FunctionName: funcObj.name,
ZipFile: zipBuffer,
};
return this.provider.request(
'Lambda',
'updateFunctionCode',
params,
this.options.stage, this.options.region
).then(() => {
this.serverless.cli.log(`Successfully rolled back function: "${this.options.function}"`);
});
}
}
module.exports = AwsRollbackFunction;

View File

@ -0,0 +1,244 @@
'use strict';
const expect = require('chai').expect;
const sinon = require('sinon');
const Serverless = require('../../../Serverless');
const AwsProvider = require('../provider/awsProvider');
const CLI = require('../../../classes/CLI');
const proxyquire = require('proxyquire');
describe('AwsRollbackFunction', () => {
let serverless;
let awsRollbackFunction;
let consoleLogStub;
let fetchStub;
let AwsRollbackFunction;
beforeEach(() => {
fetchStub = sinon.stub().resolves({ buffer: () => {} });
AwsRollbackFunction = proxyquire('./index.js', {
'node-fetch': fetchStub,
});
serverless = new Serverless();
serverless.servicePath = true;
serverless.service.functions = {
hello: {
handler: true,
name: 'service-dev-hello',
},
};
const options = {
stage: 'dev',
region: 'us-east-1',
function: 'hello',
};
serverless.setProvider('aws', new AwsProvider(serverless));
serverless.cli = new CLI(serverless);
awsRollbackFunction = new AwsRollbackFunction(serverless, options);
consoleLogStub = sinon.stub(serverless.cli, 'consoleLog').returns();
});
afterEach(() => {
serverless.cli.consoleLog.restore();
});
describe('#constructor()', () => {
let validateStub;
let getFunctionToBeRestoredStub;
let fetchFunctionCodeStub;
let restoreFunctionStub;
beforeEach(() => {
validateStub = sinon
.stub(awsRollbackFunction, 'validate').resolves();
getFunctionToBeRestoredStub = sinon
.stub(awsRollbackFunction, 'getFunctionToBeRestored').resolves();
fetchFunctionCodeStub = sinon
.stub(awsRollbackFunction, 'fetchFunctionCode').resolves();
restoreFunctionStub = sinon
.stub(awsRollbackFunction, 'restoreFunction').resolves();
});
afterEach(() => {
awsRollbackFunction.validate.restore();
awsRollbackFunction.getFunctionToBeRestored.restore();
awsRollbackFunction.fetchFunctionCode.restore();
awsRollbackFunction.restoreFunction.restore();
});
it('should have hooks', () => expect(awsRollbackFunction.hooks).to.be.not.empty);
it('should have commands', () => expect(awsRollbackFunction.commands).to.be.not.empty);
it('should set the provider variable to an instance of AwsProvider', () =>
expect(awsRollbackFunction.provider).to.be.instanceof(AwsProvider));
it('should set an empty options object if no options are given', () => {
const awsRollbackFunctionWithEmptyOptions = new AwsRollbackFunction(serverless);
expect(awsRollbackFunctionWithEmptyOptions.options).to.deep.equal({});
});
it('should run promise chain in order', () => awsRollbackFunction
.hooks['rollback:function:rollback']().then(() => {
expect(validateStub.calledOnce).to.equal(true);
expect(getFunctionToBeRestoredStub.calledAfter(validateStub)).to.equal(true);
expect(fetchFunctionCodeStub.calledAfter(getFunctionToBeRestoredStub)).to.equal(true);
expect(restoreFunctionStub.calledAfter(fetchFunctionCodeStub)).to.equal(true);
})
);
});
describe('#getFunctionToBeRestored()', () => {
describe('when function and version can be found', () => {
let getFunctionStub;
beforeEach(() => {
getFunctionStub = sinon
.stub(awsRollbackFunction.provider, 'request')
.resolves({ function: 'hello' });
});
afterEach(() => {
awsRollbackFunction.provider.request.restore();
});
it('should return the requested function', () => {
awsRollbackFunction.options.function = 'hello';
awsRollbackFunction.options.version = '4711';
return awsRollbackFunction.getFunctionToBeRestored().then((result) => {
expect(getFunctionStub.calledOnce).to.equal(true);
expect(getFunctionStub.calledWithExactly(
'Lambda',
'getFunction',
{
FunctionName: 'service-dev-hello',
Qualifier: '4711',
},
awsRollbackFunction.options.stage,
awsRollbackFunction.options.region
)).to.equal(true);
expect(consoleLogStub.called).to.equal(true);
expect(result).to.deep.equal({ function: 'hello' });
});
});
});
describe('when function or version could not be found', () => {
let getFunctionStub;
beforeEach(() => {
getFunctionStub = sinon
.stub(awsRollbackFunction.provider, 'request')
.rejects(new Error('function hello not found'));
});
afterEach(() => {
awsRollbackFunction.provider.request.restore();
});
it('should translate the error message to a custom error message', () => {
awsRollbackFunction.options.function = 'hello';
awsRollbackFunction.options.version = '4711';
return awsRollbackFunction.getFunctionToBeRestored().catch((error) => {
expect(error.message.match(/Function "hello" with version "4711" not found/));
expect(getFunctionStub.calledOnce).to.equal(true);
expect(getFunctionStub.calledWithExactly(
'Lambda',
'getFunction',
{
FunctionName: 'service-dev-hello',
Qualifier: '4711',
},
awsRollbackFunction.options.stage,
awsRollbackFunction.options.region
)).to.equal(true);
expect(consoleLogStub.called).to.equal(true);
});
});
});
describe('when other error occurred', () => {
let getFunctionStub;
beforeEach(() => {
getFunctionStub = sinon
.stub(awsRollbackFunction.provider, 'request')
.rejects(new Error('something went wrong'));
});
afterEach(() => {
awsRollbackFunction.provider.request.restore();
});
it('should re-throw the error without translating it to a custom error message', () => {
awsRollbackFunction.options.function = 'hello';
awsRollbackFunction.options.version = '4711';
return awsRollbackFunction.getFunctionToBeRestored().catch((error) => {
expect(error.message.match(/something went wrong/));
expect(getFunctionStub.calledOnce).to.equal(true);
expect(getFunctionStub.calledWithExactly(
'Lambda',
'getFunction',
{
FunctionName: 'service-dev-hello',
Qualifier: '4711',
},
awsRollbackFunction.options.stage,
awsRollbackFunction.options.region
)).to.equal(true);
expect(consoleLogStub.called).to.equal(true);
});
});
});
});
describe('#fetchFunctionCode()', () => {
it('should fetch the zip file content of the previously requested function', () => {
const func = {
Code: {
Location: 'https://foo.com/bar',
},
};
return awsRollbackFunction.fetchFunctionCode(func).then(() => {
expect(fetchStub.called).to.equal(true);
});
});
});
describe('#restoreFunction()', () => {
let updateFunctionCodeStub;
beforeEach(() => {
updateFunctionCodeStub = sinon
.stub(awsRollbackFunction.provider, 'request').resolves();
});
afterEach(() => {
awsRollbackFunction.provider.request.restore();
});
it('should restore the provided function', () => {
awsRollbackFunction.options.function = 'hello';
const zipBuffer = new Buffer('');
return awsRollbackFunction.restoreFunction(zipBuffer).then(() => {
expect(updateFunctionCodeStub.calledOnce).to.equal(true);
expect(updateFunctionCodeStub.calledWithExactly(
'Lambda',
'updateFunctionCode',
{
FunctionName: 'service-dev-hello',
ZipFile: zipBuffer,
},
awsRollbackFunction.options.stage,
awsRollbackFunction.options.region
)).to.equal(true);
expect(consoleLogStub.called).to.equal(true);
});
});
});
});

View File

@ -67,6 +67,14 @@ class Deploy {
lifecycleEvents: [
'log',
],
commands: {
functions: {
usage: 'List all the deployed functions and their versions',
lifecycleEvents: [
'log',
],
},
},
},
},
},

View File

@ -22,6 +22,29 @@ class Rollback {
shortcut: 'v',
},
},
commands: {
function: {
usage: 'Rollback the function to the previous version',
lifecycleEvents: [
'rollback',
],
options: {
function: {
usage: 'Name of the function',
shortcut: 'f',
required: true,
},
stage: {
usage: 'Stage of the function',
shortcut: 's',
},
region: {
usage: 'Region of the function',
shortcut: 'r',
},
},
},
},
},
};
}

View File

@ -14,21 +14,41 @@ describe('Rollback', () => {
});
describe('#constructor()', () => {
it('should have the command rollback', () => {
// eslint-disable-next-line no-unused-expressions
expect(rollback.commands.rollback).to.not.be.undefined;
describe('when dealing with normal rollbacks', () => {
it('should have the command "rollback"', () => {
// eslint-disable-next-line no-unused-expressions
expect(rollback.commands.rollback).to.not.be.undefined;
});
it('should have a lifecycle events initialize and rollback', () => {
expect(rollback.commands.rollback.lifecycleEvents).to.deep.equal([
'initialize',
'rollback',
]);
});
it('should have a required option timestamp', () => {
// eslint-disable-next-line no-unused-expressions
expect(rollback.commands.rollback.options.timestamp.required).to.be.true;
});
});
it('should have a lifecycle events initialize and rollback', () => {
expect(rollback.commands.rollback.lifecycleEvents).to.deep.equal([
'initialize',
'rollback',
]);
});
describe('when dealing with function rollbacks', () => {
it('should have the command "rollback function"', () => {
// eslint-disable-next-line no-unused-expressions
expect(rollback.commands.rollback.commands.function).to.not.be.undefined;
});
it('should have a required option timestamp', () => {
// eslint-disable-next-line no-unused-expressions
expect(rollback.commands.rollback.options.timestamp.required).to.be.true;
it('should have a lifecycle event rollback', () => {
expect(rollback.commands.rollback.commands.function.lifecycleEvents).to.deep.equal([
'rollback',
]);
});
it('should have a required option function', () => {
// eslint-disable-next-line no-unused-expressions
expect(rollback.commands.rollback.commands.function.options.function.required).to.be.true;
});
});
});
});

View File

@ -99,7 +99,7 @@
"lodash": "^4.13.1",
"minimist": "^1.2.0",
"moment": "^2.13.0",
"node-fetch": "^1.5.3",
"node-fetch": "^1.6.0",
"replaceall": "^0.1.6",
"resolve-from": "^2.0.0",
"semver": "^5.0.3",