Repository: HyperBrain/serverless-aws-alias (js)

Issue:
Serverless logs does not work with alias plugin
Invoking `serverless logs` leads to error message.

```
$ node node_modules/serverless/bin/serverless logs --stage=dev --function=authen               ticate

  Serverless Error ---------------------------------------

     1 validation error detected: Value '[]' at 'logStreamNames'
     failed to satisfy constraint: Member must have length
     greater than or equal to 1

  Get Support --------------------------------------------
     Docs:          docs.serverless.com
     Bugs:          github.com/serverless/serverless/issues
     Forums:        forum.serverless.com
     Chat:          gitter.im/serverless/serverless

  Your Environment Information -----------------------------
     OS:                 win32
     Node Version:       6.10.1
     Serverless Version: 1.12.1
```

Additionally to this defect, the alias plugin should add support for the `--alias` option to show the log from an aliased function.

Pull request description:
Support sls logs command
Fixes #38 

Added logs command that uses a given alias option or defaults to the master alias.
All functionaliy of the Serverless logs command is supported.

Patch:
diff --git a/README.md b/README.md
index 07e4cc8..74c07b7 100644
--- a/README.md
+++ b/README.md
@@ -84,14 +84,6 @@ aliased lambda is the origin.
 Any other use with the further exception of lambda event subscriptions (see below)
 is strongly discouraged.
 
-## Log groups (not yet finished)
-
-Each alias has its own log group. From my experience with Serverless 0.5 where
-all aliased versions put their logs into the same group, this should be much
-cleaner and the removal of an alias will also remove all logs associated to the alias.
-The log group is named `/serverless/<alias stack name>`. So you can clearly see
-what is deployed through Serverless and what by other means.
-
 ## Resources
 
 Resources are deployed per alias. So you can create new resources without destroying
@@ -202,6 +194,15 @@ In verbose mode (`serverless info -v`) it will additionally print the names
 of the lambda functions deployed to each stage with the version numbers the
 alias points to.
 
+Given an alias with `--alias=XXXX` info will show information for the alias.
+
+## Serverless logs integration
+
+The plugin integrates with the Serverless logs command (all standard options will
+work). Additionally, given an alias with `--alias=XXXX`, logs will show the logs
+for the selected alias. Without the alias option it will show the master alias
+(aka. stage alias).
+
 ## The alias command
 
 ## Subcommands
diff --git a/index.js b/index.js
index 98bbda6..3d862cc 100644
--- a/index.js
+++ b/index.js
@@ -15,6 +15,7 @@ const BbPromise = require('bluebird')
 		, stackInformation = require('./lib/stackInformation')
 		, listAliases = require('./lib/listAliases')
 		, removeAlias = require('./lib/removeAlias')
+		, logs = require('./lib/logs')
 		, collectUserResources = require('./lib/collectUserResources')
 		, uploadAliasArtifacts = require('./lib/uploadAliasArtifacts');
 
@@ -57,6 +58,7 @@ class AwsAlias {
 			createAliasStack,
 			updateAliasStack,
 			listAliases,
+			logs,
 			removeAlias,
 			aliasRestructureStack,
 			stackInformation,
@@ -118,6 +120,15 @@ class AwsAlias {
 			'after:info:info': () => BbPromise.bind(this)
 				.then(this.listAliases),
 
+			// Override the logs command - must be, because the $LATEST filter
+			// in the original logs command is not easy to change without hacks.
+			'before:logs:logs': () => BbPromise.bind(this)
+				.then(this.validate)
+				.then(this.logsValidate)
+				.then(this.logsGetLogStreams)
+				.then(this.logsShowLogs)
+				.then(() => process.exit(0)),	// Bail out to prevent the roiginal logs to be started!
+
 			'alias:remove:remove': () => BbPromise.bind(this)
 				.then(this.validate)
 				.then(this.aliasStackLoadCurrentCFStackAndDependencies)
diff --git a/lib/listAliases.js b/lib/listAliases.js
index 0bae648..1d1e80f 100644
--- a/lib/listAliases.js
+++ b/lib/listAliases.js
@@ -43,15 +43,6 @@ module.exports = {
 		});
 	},
 
-	listDescribeStack(stackName) {
-
-		return this._provider.request('CloudFormation',
-			'describeStackResources',
-			{ StackName: stackName },
-			this._options.stage,
-			this._options.region);
-	},
-
 	listGetApiId(stackName) {
 
 		return this._provider.request('CloudFormation',
@@ -86,18 +77,13 @@ module.exports = {
 
 					if (this._options.verbose) {
 						return BbPromise.join(
-							this.listDescribeStack(`${this._provider.naming.getStackName()}-${aliasName}`),
+							this.aliasGetAliasFunctionVersions(aliasName),
 							this.listDescribeApiStage(apiId, aliasName)
 						)
-						.spread((resources /*, apiStage */) => {
-							const versions = _.filter(resources.StackResources, [ 'ResourceType', 'AWS::Lambda::Version' ]);
-
+						.spread((versions /*, apiStage */) => {
 							console.log(chalk.white('    Functions:'));
 							_.forEach(versions, version => {
-								const functionName = /:function:(.*):/.exec(version.PhysicalResourceId)[1];
-								const functionVersion = _.last(_.split(version.PhysicalResourceId, ':'));
-
-								console.log(chalk.yellow(`      ${functionName} -> ${functionVersion}`));
+								console.log(chalk.yellow(`      ${version.functionName} -> ${version.functionVersion}`));
 
 								// Print deployed endpoints for the function
 								// FIXME: Check why APIG getStage and getDeployment do not return the stage API layout.
diff --git a/lib/logs.js b/lib/logs.js
new file mode 100644
index 0000000..4a6f88a
--- /dev/null
+++ b/lib/logs.js
@@ -0,0 +1,149 @@
+'use strict';
+/**
+ * Log management.
+ */
+
+const BbPromise = require('bluebird');
+const _ = require('lodash');
+const chalk = require('chalk');
+const moment = require('moment');
+const os = require('os');
+
+module.exports = {
+	logsValidate() {
+		// validate function exists in service
+		this._lambdaName = this._serverless.service.getFunction(this.options.function).name;
+
+		this._options.interval = this._options.interval || 1000;
+		this._options.logGroupName = this._provider.naming.getLogGroupName(this._lambdaName);
+
+		return BbPromise.resolve();
+	},
+
+	logsGetLogStreams() {
+		const params = {
+			logGroupName: this._options.logGroupName,
+			descending: true,
+			limit: 50,
+			orderBy: 'LastEventTime',
+		};
+
+		// Get currently deployed function version for the alias to
+		// setup the stream filter correctly
+		return this.aliasGetAliasFunctionVersions(this._alias)
+		.then(versions => {
+			return _.map(
+				_.filter(versions, [ 'functionName', this._lambdaName ]),
+				version => version.functionVersion);
+		})
+		.then(version => {
+			return this.provider
+				.request('CloudWatchLogs',
+					'describeLogStreams',
+					params,
+					this.options.stage,
+					this.options.region)
+				.then(reply => {
+					if (!reply || _.isEmpty(reply.logStreams)) {
+						throw new this.serverless.classes
+							.Error('No existing streams for the function alias');
+					}
+
+					return _.map(
+						_.filter(reply.logStreams, stream => _.includes(stream.logStreamName, `[${version}]`)),
+						stream => stream.logStreamName);
+				});
+		});
+
+	},
+
+	logsShowLogs(logStreamNames) {
+		if (!logStreamNames || !logStreamNames.length) {
+			if (this.options.tail) {
+				return setTimeout((() => this.getLogStreams()
+					.then(nextLogStreamNames => this.showLogs(nextLogStreamNames))),
+					this.options.interval);
+			}
+		}
+
+		const formatLambdaLogEvent = (msgParam) => {
+			let msg = msgParam;
+			const dateFormat = 'YYYY-MM-DD HH:mm:ss.SSS (Z)';
+
+			if (_.startsWith(msg, 'REPORT')) {
+				msg += os.EOL;
+			}
+
+			if (_.startsWith(msg, 'START') || _.startsWith(msg, 'END') || _.startsWith(msg, 'REPORT')) {
+				return chalk.gray(msg);
+			} else if (_.trim(msg) === 'Process exited before completing request') {
+				return chalk.red(msg);
+			}
+
+			const splitted = _.split(msg, '\t');
+
+			if (splitted.length < 3 || new Date(splitted[0]) === 'Invalid Date') {
+				return msg;
+			}
+			const reqId = splitted[1];
+			const time = chalk.green(moment(splitted[0]).format(dateFormat));
+			const text = _.split(msg, `${reqId}\t`)[1];
+
+			return `${time}\t${chalk.yellow(reqId)}\t${text}`;
+		};
+
+		const params = {
+			logGroupName: this.options.logGroupName,
+			interleaved: true,
+			logStreamNames,
+			startTime: this.options.startTime,
+		};
+
+		if (this.options.filter) params.filterPattern = this.options.filter;
+		if (this.options.nextToken) params.nextToken = this.options.nextToken;
+		if (this.options.startTime) {
+			const since = _.includes(['m', 'h', 'd'],
+				this.options.startTime[this.options.startTime.length - 1]);
+			if (since) {
+				params.startTime = moment().subtract(
+					_.replace(this.options.startTime, /\D/g, ''),
+					_.replace(this.options.startTime, /\d/g, '')).valueOf();
+			} else {
+				params.startTime = moment.utc(this.options.startTime).valueOf();
+			}
+		}
+
+		return this.provider
+			.request('CloudWatchLogs',
+				'filterLogEvents',
+				params,
+				this.options.stage,
+				this.options.region)
+			.then(results => {
+				if (results.events) {
+					_.forEach(results.events, e => {
+						process.stdout.write(formatLambdaLogEvent(e.message));
+					});
+				}
+
+				if (results.nextToken) {
+					this.options.nextToken = results.nextToken;
+				} else {
+					delete this.options.nextToken;
+				}
+
+				if (this.options.tail) {
+					if (results.events && results.events.length) {
+						this.options.startTime = _.last(results.events).timestamp + 1;
+					}
+
+					return setTimeout((() => this.getLogStreams()
+							.then(nextLogStreamNames => this.showLogs(nextLogStreamNames))),
+						this.options.interval);
+				}
+
+				return BbPromise.resolve();
+			});
+	},
+
+};
diff --git a/lib/stackInformation.js b/lib/stackInformation.js
index 99ffe7e..ae6586b 100644
--- a/lib/stackInformation.js
+++ b/lib/stackInformation.js
@@ -128,7 +128,6 @@ module.exports = {
 	},
 
 	aliasStackLoadCurrentCFStackAndDependencies() {
-
 		return BbPromise.join(
 			BbPromise.bind(this).then(this.aliasStackLoadCurrentTemplate),
 			BbPromise.bind(this).then(this.aliasStackLoadAliasTemplates)
@@ -148,4 +147,24 @@ module.exports = {
 		});
 	},
 
+	aliasDescribeAliasStack(aliasName) {
+		const stackName = `${this._provider.naming.getStackName()}-${aliasName}`;
+		return this._provider.request('CloudFormation',
+			'describeStackResources',
+			{ StackName: stackName },
+			this._options.stage,
+			this._options.region);
+	},
+
+	aliasGetAliasFunctionVersions(aliasName) {
+		return this.aliasDescribeAliasStack(aliasName)
+		.then(resources => {
+			const versions = _.filter(resources.StackResources, [ 'ResourceType', 'AWS::Lambda::Version' ]);
+			return _.map(versions, version => ({
+				functionName: /:function:(.*):/.exec(version.PhysicalResourceId)[1],
+				functionVersion: _.last(_.split(version.PhysicalResourceId, ':'))
+			}));
+		});
+	},
+
 };
diff --git a/package.json b/package.json
index e3c44e1..466636b 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,8 @@
     "bluebird": "^3.4.7",
     "chalk": "^1.1.3",
     "lodash": "^4.17.4",
+    "moment": "^2.18.1",
+    "os": "^0.1.1",
     "semver": "^5.3.0"
   },
   "devDependencies": {
@@ -43,9 +45,9 @@
     "eslint-plugin-promise": "^3.4.0",
     "get-installed-path": "^2.0.3",
     "istanbul": "^0.4.5",
-    "mocha": "^3.3.0",
-    "serverless": "^1.12.1",
-    "sinon": "^2.2.0",
+    "mocha": "^3.4.1",
+    "serverless": "^1.13.2",
+    "sinon": "^2.3.1",
     "sinon-chai": "^2.10.0"
   }
 }


Test patch:
diff --git a/test/logs.test.js b/test/logs.test.js
new file mode 100644
index 0000000..590d8a3
--- /dev/null
+++ b/test/logs.test.js
@@ -0,0 +1,264 @@
+'use strict';
+
+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('logs', () => {
+	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: 'dev',
+			region: 'us-east-1',
+			function: 'first'
+		};
+		serverless.setProvider('aws', new AwsProvider(serverless));
+		serverless.cli = new serverless.classes.CLI(serverless);
+		serverless.service.service = 'testService';
+		serverless.service.provider.compiledCloudFormationAliasTemplate = {};
+		awsAlias = new AWSAlias(serverless, options);
+		//providerRequestStub = sandbox.stub(awsAlias._provider, 'request');
+		logStub = sandbox.stub(serverless.cli, 'log');
+
+		logStub.returns();
+	});
+
+	afterEach(() => {
+		sandbox.restore();
+	});
+
+	describe('#logsValidate()', () => {
+		beforeEach(() => {
+			serverless.config.servicePath = true;
+			serverless.service.environment = {
+				vars: {},
+				stages: {
+					dev: {
+						vars: {},
+						regions: {
+							'us-east-1': {
+								vars: {},
+							},
+						},
+					},
+				},
+			};
+			serverless.service.functions = {
+				first: {
+					handler: true,
+					name: 'customName',
+				},
+			};
+		});
+
+		it('it should throw error if function is not provided', () => {
+			serverless.service.functions = null;
+			expect(() => awsAlias.logsValidate()).to.throw(Error);
+		});
+
+		it('it should set default options', () => {
+			return expect(awsAlias.logsValidate()).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(awsAlias.options.stage).to.deep.equal('dev'),
+				expect(awsAlias.options.region).to.deep.equal('us-east-1'),
+				expect(awsAlias.options.function).to.deep.equal('first'),
+				expect(awsAlias.options.interval).to.be.equal(1000),
+				expect(awsAlias.options.logGroupName).to.deep.equal(awsAlias.provider.naming
+					.getLogGroupName('customName'))
+			]));
+		});
+	});
+
+	describe('#logsGetLogStreams()', () => {
+		beforeEach(() => {
+			awsAlias.serverless.service.service = 'new-service';
+			awsAlias._options = {
+				stage: 'dev',
+				region: 'us-east-1',
+				function: 'first',
+				logGroupName: awsAlias.provider.naming.getLogGroupName('new-service-dev-first'),
+			};
+		});
+
+		/** TODO: Use fake alias log stream responses here!
+		it('should get log streams with correct params', () => {
+			const replyMock = {
+				logStreams: [
+					{
+						logStreamName: '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+						creationTime: 1469687512311,
+					},
+					{
+						logStreamName: '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+						creationTime: 1469687512311,
+					},
+				],
+			};
+			providerRequestStub.resolves(replyMock);
+
+			return expect(awsAlias.logsGetLogStreams()).to.be.fulfilled
+			.then(logStreamNames => BbPromise.all([
+				expect(providerRequestStub).to.have.been.calledTwice,
+				expect(providerRequestStub).to.have.been.calledWithExactly(
+					'CloudWatchLogs',
+					'describeLogStreams',
+					{
+						logGroupName: awsAlias.provider.naming.getLogGroupName('new-service-dev-first'),
+						descending: true,
+						limit: 50,
+						orderBy: 'LastEventTime',
+					},
+					awsAlias.options.stage,
+					awsAlias.options.region
+				),
+				expect(logStreamNames[0])
+					.to.be.equal('2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba'),
+				expect(logStreamNames[1])
+					.to.be.equal('2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba'),
+			]));
+		});
+
+		it('should throw error if no log streams found', () => {
+			providerRequestStub.resolves();
+			return expect(awsAlias.logsGetLogStreams()).to.be.rejectedWith("");
+		});
+	});
+
+	describe('#logsShowLogs()', () => {
+		let clock;
+
+		beforeEach(() => {
+			// new Date() => return the fake Date 'Sat Sep 01 2016 00:00:00'
+			clock = sinon.useFakeTimers(new Date(Date.UTC(2016, 9, 1)).getTime());
+		});
+
+		afterEach(() => {
+			// new Date() => will return the real time again (now)
+			clock.restore();
+		});
+
+		it('should call filterLogEvents API with correct params', () => {
+			const replyMock = {
+				events: [
+					{
+						logStreamName: '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+						timestamp: 1469687512311,
+						message: 'test',
+					},
+					{
+						logStreamName: '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+						timestamp: 1469687512311,
+						message: 'test',
+					},
+				],
+			};
+			const logStreamNamesMock = [
+				'2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+				'2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+			];
+			providerRequestStub.resolves(replyMock);
+			awsAlias.serverless.service.service = 'new-service';
+			awsAlias._options = {
+				stage: 'dev',
+				region: 'us-east-1',
+				function: 'first',
+				logGroupName: awsAlias.provider.naming.getLogGroupName('new-service-dev-first'),
+				startTime: '3h',
+				filter: 'error',
+			};
+
+			return expect(awsAlias.logsShowLogs(logStreamNamesMock)).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(providerRequestStub).to.have.been.calledOnce,
+				expect(providerRequestStub).to.have.been.calledWithExactly(
+					'CloudWatchLogs',
+					'filterLogEvents',
+					{
+						logGroupName: awsAlias.provider.naming.getLogGroupName('new-service-dev-first'),
+						interleaved: true,
+						logStreamNames: logStreamNamesMock,
+						filterPattern: 'error',
+						startTime: 1475269200000,
+					},
+					awsAlias.options.stage,
+					awsAlias.options.region
+				),
+			]));
+		});
+
+		it('should call filterLogEvents API with standard start time', () => {
+			const replyMock = {
+				events: [
+					{
+						logStreamName: '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+						timestamp: 1469687512311,
+						message: 'test',
+					},
+					{
+						logStreamName: '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+						timestamp: 1469687512311,
+						message: 'test',
+					},
+				],
+			};
+			const logStreamNamesMock = [
+				'2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+				'2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba',
+			];
+			const filterLogEventsStub = sinon.stub(awsLogs.provider, 'request').resolves(replyMock);
+			awsLogs.serverless.service.service = 'new-service';
+			awsLogs.options = {
+				stage: 'dev',
+				region: 'us-east-1',
+				function: 'first',
+				logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'),
+				startTime: '2010-10-20',
+				filter: 'error',
+			};
+
+			return awsLogs.showLogs(logStreamNamesMock)
+				.then(() => {
+					expect(filterLogEventsStub.calledOnce).to.be.equal(true);
+					expect(filterLogEventsStub.calledWithExactly(
+						'CloudWatchLogs',
+						'filterLogEvents',
+						{
+							logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'),
+							interleaved: true,
+							logStreamNames: logStreamNamesMock,
+							startTime: 1287532800000, // '2010-10-20'
+							filterPattern: 'error',
+						},
+						awsLogs.options.stage,
+						awsLogs.options.region
+					)).to.be.equal(true);
+
+					awsLogs.provider.request.restore();
+				});
+		});
+		*/
+	});
+});