From effae1db1db1ec39732428dc4e0bbdd86acbb23c Mon Sep 17 00:00:00 2001 From: "Eslam A. Hefnawy" Date: Wed, 27 Jul 2016 21:27:21 +0900 Subject: [PATCH] added logs plugin --- .gitignore | 1 - lib/plugins/Plugins.json | 2 + lib/plugins/aws/logs/README.md | 11 ++ lib/plugins/aws/logs/index.js | 151 ++++++++++++++++++++++++ lib/plugins/aws/logs/tests/index.js | 174 ++++++++++++++++++++++++++++ lib/plugins/logs/README.md | 48 ++++++++ lib/plugins/logs/logs.js | 49 ++++++++ lib/plugins/logs/tests/logs.js | 19 +++ 8 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 lib/plugins/aws/logs/README.md create mode 100644 lib/plugins/aws/logs/index.js create mode 100644 lib/plugins/aws/logs/tests/index.js create mode 100644 lib/plugins/logs/README.md create mode 100644 lib/plugins/logs/logs.js create mode 100644 lib/plugins/logs/tests/logs.js diff --git a/.gitignore b/.gitignore index 8e14128a1..9e1a89e4c 100755 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Logs -logs *.log npm-debug.log diff --git a/lib/plugins/Plugins.json b/lib/plugins/Plugins.json index e1872f414..8166cc196 100644 --- a/lib/plugins/Plugins.json +++ b/lib/plugins/Plugins.json @@ -5,11 +5,13 @@ "./deploy/deploy.js", "./invoke/invoke.js", "./info/info.js", + "./logs/logs.js", "./remove/remove.js", "./tracking/tracking.js", "./aws/deploy/index.js", "./aws/invoke/index.js", "./aws/info/index.js", + "./aws/logs/index.js", "./aws/remove/index.js", "./aws/deploy/compile/functions/index.js", "./aws/deploy/compile/events/schedule/index.js", diff --git a/lib/plugins/aws/logs/README.md b/lib/plugins/aws/logs/README.md new file mode 100644 index 000000000..7c347ec69 --- /dev/null +++ b/lib/plugins/aws/logs/README.md @@ -0,0 +1,11 @@ +# Invoke + +This plugin invokes a lambda function. + +## How it works + +`Invoke` hooks into the [`invoke:invoke`](/lib/plugins/invoke) lifecycle. It will run the `invoke` command +which is provided by the AWS SDK on the function the user passes in as a parameter. + +The output of the function is fetched and will be prompted on the console. + diff --git a/lib/plugins/aws/logs/index.js b/lib/plugins/aws/logs/index.js new file mode 100644 index 000000000..c6d3f1848 --- /dev/null +++ b/lib/plugins/aws/logs/index.js @@ -0,0 +1,151 @@ +'use strict'; + +const BbPromise = require('bluebird'); +const chalk = require('chalk'); +const _ = require('lodash'); +const SDK = require('../'); +const moment = require('moment'); +const validate = require('../lib/validate'); + +class AwsLogs { + constructor(serverless, options) { + this.serverless = serverless; + this.options = options || {}; + this.provider = 'aws'; + this.sdk = new SDK(serverless); + + Object.assign(this, validate); + + this.hooks = { + 'logs:logs': () => BbPromise.bind(this) + .then(this.extendedValidate) + .then(this.getLogStreams) + .then(this.showLogs), + }; + } + + extendedValidate() { + this.validate(); + + // validate function exists in service + this.serverless.service.getFunction(this.options.function); + + if (!this.options.pollInterval) this.options.pollInterval = 1000; + if (!this.options.duration) this.options.duration = '5m'; + + this.options.startTime = moment().subtract(this.options + .duration.replace(/\D/g,''), this.options + .duration.replace(/\d/g,'')).valueOf(); + + this.options.lambdaName = `${this.serverless.service + .service}-${this.options + .stage}-${this.options + .function}`; + + this.options.logGroupName = `/aws/lambda/${this.serverless.service + .service}-${this.options + .stage}-${this.options + .function}`; + + return BbPromise.resolve(); + } + + getLogStreams() { + const params = { + logGroupName: this.options.logGroupName, + descending: true, + limit: 50, + orderBy: 'LastEventTime', + }; + + return this.sdk + .request('CloudWatchLogs', + 'describeLogStreams', + params, + this.options.stage, + this.options.region) + .then(reply => { + if (reply.logStreams.length === 0) { + throw new this.serverless.classes + .Error('No existing streams for the function'); + } + + return _.chain(reply.logStreams) + .map('logStreamName') + .value(); + }); + } + + showLogs(logStreamNames) { + if (!logStreamNames.length) { + if (this.evt.options.tail) { + return setTimeout((() => this.showLogs()), this.options.pollInterval); + } + throw new this.serverless.classes + .Error('No existing streams for the function'); + } + + 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; + + const formatLambdaLogEvent = (msg) => { + const dateFormat = 'YYYY-MM-DD HH:mm:ss.SSS (Z)'; + + if (msg.startsWith('START') || msg.startsWith('END') || msg.startsWith('REPORT')) { + return chalk.gray(msg); + } else if (msg.trim() === 'Process exited before completing request') { + return chalk.red(msg); + } + + const splitted = msg.split('\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 = msg.split(`${reqId}\t`)[1]; + + return `${time}\t${chalk.yellow(reqId)}\t${text}`; + }; + + return this.sdk + .request('CloudWatchLogs', + 'filterLogEvents', + params, + this.options.stage, + this.options.region) + .then(results => { + if (results.events) { + results.events.forEach(e => { + process.stdout.write(formatLambdaLogEvent(e.message)); + }); + } + + if (results.nextToken) { + this.options.nextToken = results.nextToken; + } else { + delete this.options.nextToken; + } + + if (this.evt.options.tail) { + if (results.events && results.events.length) { + this.options.startTime = _.last(results.events).timestamp + 1; + } + + return setTimeout((() => this.showLogs()), this.evt.options.pollInterval); + } + + return BbPromise.resolve(); + }); + } +} + +module.exports = AwsLogs; diff --git a/lib/plugins/aws/logs/tests/index.js b/lib/plugins/aws/logs/tests/index.js new file mode 100644 index 000000000..60602389f --- /dev/null +++ b/lib/plugins/aws/logs/tests/index.js @@ -0,0 +1,174 @@ +'use strict'; + +const expect = require('chai').expect; +const sinon = require('sinon'); +const path = require('path'); +const os = require('os'); +const AwsInvoke = require('../'); +const Serverless = require('../../../../Serverless'); +const BbPromise = require('bluebird'); + +describe('AwsInvoke', () => { + const serverless = new Serverless(); + const options = { + stage: 'dev', + region: 'us-east-1', + function: 'first', + }; + const awsInvoke = new AwsInvoke(serverless, options); + + describe('#constructor()', () => { + it('should have hooks', () => expect(awsInvoke.hooks).to.be.not.empty); + + it('should set the provider variable to "aws"', () => expect(awsInvoke.provider) + .to.equal('aws')); + + it('should run promise chain in order', () => { + const validateStub = sinon + .stub(awsInvoke, 'extendedValidate').returns(BbPromise.resolve()); + const invokeStub = sinon + .stub(awsInvoke, 'invoke').returns(BbPromise.resolve()); + const logStub = sinon + .stub(awsInvoke, 'log').returns(BbPromise.resolve()); + + return awsInvoke.hooks['invoke:invoke']().then(() => { + expect(validateStub.calledOnce).to.be.equal(true); + expect(invokeStub.calledAfter(validateStub)).to.be.equal(true); + expect(logStub.calledAfter(invokeStub)).to.be.equal(true); + + awsInvoke.extendedValidate.restore(); + awsInvoke.invoke.restore(); + awsInvoke.log.restore(); + }); + }); + }); + + describe('#extendedValidate()', () => { + beforeEach(() => { + serverless.config.servicePath = true; + serverless.service.environment = { + vars: {}, + stages: { + dev: { + vars: {}, + regions: { + 'us-east-1': { + vars: {}, + }, + }, + }, + }, + }; + serverless.service.functions = { + first: { + handler: true, + }, + }; + }); + + it('it should throw error if function is not provided', () => { + serverless.service.functions = null; + expect(() => awsInvoke.extendedValidate()).to.throw(Error); + }); + + it('it should parse file if file path is provided', () => { + serverless.config.servicePath = path.join(os.tmpdir(), (new Date).getTime().toString()); + const data = { + testProp: 'testValue', + }; + serverless.utils.writeFileSync(path + .join(serverless.config.servicePath, 'data.json'), JSON.stringify(data)); + awsInvoke.options.path = 'data.json'; + + return awsInvoke.extendedValidate().then(() => { + expect(awsInvoke.options.data).to.deep.equal(data); + awsInvoke.options.path = false; + serverless.config.servicePath = true; + }); + }); + + it('it should throw error if service path is not set', () => { + serverless.config.servicePath = false; + expect(() => awsInvoke.extendedValidate()).to.throw(Error); + serverless.config.servicePath = true; + }); + + it('it should throw error if file path does not exist', () => { + serverless.config.servicePath = path.join(os.tmpdir(), (new Date).getTime().toString()); + awsInvoke.options.path = 'some/path'; + + expect(() => awsInvoke.extendedValidate()).to.throw(Error); + + awsInvoke.options.path = false; + serverless.config.servicePath = true; + }); + }); + + describe('#invoke()', () => { + let invokeStub; + beforeEach(() => { + invokeStub = sinon.stub(awsInvoke.sdk, 'request'). + returns(BbPromise.resolve()); + awsInvoke.serverless.service.service = 'new-service'; + awsInvoke.options = { + stage: 'dev', + function: 'first', + }; + }); + + it('should invoke with correct params', () => awsInvoke.invoke() + .then(() => { + expect(invokeStub.calledOnce).to.be.equal(true); + expect(invokeStub.calledWith(awsInvoke.options.stage, awsInvoke.options.region)); + expect(invokeStub.args[0][2].FunctionName).to.be.equal('new-service-dev-first'); + expect(invokeStub.args[0][2].InvocationType).to.be.equal('RequestResponse'); + expect(invokeStub.args[0][2].LogType).to.be.equal('None'); + expect(typeof invokeStub.args[0][2].Payload).to.not.be.equal('undefined'); + awsInvoke.sdk.request.restore(); + }) + ); + + it('should invoke and log', () => { + awsInvoke.options.log = true; + + return awsInvoke.invoke().then(() => { + expect(invokeStub.calledOnce).to.be.equal(true); + expect(invokeStub.calledWith(awsInvoke.options.stage, awsInvoke.options.region)); + expect(invokeStub.args[0][2].FunctionName).to.be.equal('new-service-dev-first'); + expect(invokeStub.args[0][2].InvocationType).to.be.equal('RequestResponse'); + expect(invokeStub.args[0][2].LogType).to.be.equal('Tail'); + expect(typeof invokeStub.args[0][2].Payload).to.not.be.equal('undefined'); + awsInvoke.sdk.request.restore(); + }); + }); + + it('should invoke with other invocation type', () => { + awsInvoke.options.type = 'OtherType'; + + return awsInvoke.invoke().then(() => { + expect(invokeStub.calledOnce).to.be.equal(true); + expect(invokeStub.calledWith(awsInvoke.options.stage, awsInvoke.options.region)); + expect(invokeStub.args[0][2].FunctionName).to.be.equal('new-service-dev-first'); + expect(invokeStub.args[0][2].InvocationType).to.be.equal('OtherType'); + expect(invokeStub.args[0][2].LogType).to.be.equal('None'); + expect(typeof invokeStub.args[0][2].Payload).to.not.be.equal('undefined'); + awsInvoke.sdk.request.restore(); + }); + }); + }); + + describe('#log()', () => { + it('should log payload', () => { + const invocationReplyMock = { + Payload: ` + { + "testProp": "testValue" + } + `, + LogResult: 'test', + }; + + return awsInvoke.log(invocationReplyMock); + }); + }); +}); diff --git a/lib/plugins/logs/README.md b/lib/plugins/logs/README.md new file mode 100644 index 000000000..6dc71a15b --- /dev/null +++ b/lib/plugins/logs/README.md @@ -0,0 +1,48 @@ +# Invoke + +``` +serverless invoke --function functionName +``` + +Invokes your deployed function and outputs the results. + +## Options +- `--function` or `-f` The name of the function in your service that you want to invoke. **Required**. +- `--stage` or `-s` The stage in your service you want to invoke your function in. +- `--region` or `-r` The region in your stage that you want to invoke your function in. +- `--path` or `-p` The path to a json file holding input data to be passed to the invoked function. This path is relative to the +root directory of the service. +- `--type` or `-t` The type of invocation. Either `RequestResponse`, `Event` or `DryRun`. Default is `RequestResponse`. +- `--log` or `-l` If set to `true` and invocation type is `RequestResponse`, it will output logging data of the invocation. +Default is `false`. + +## Provided lifecycle events +- `invoke:invoke` + +## Examples + +### Simple function invocation + +``` +serverless invoke --function functionName --stage dev --region us-east-1 +``` + +This example will invoke your deployed function named `functionName` in region `us-east-1` in stage `dev`. This will +output the result of the invocation in your terminal. + +### Function invocation with logging + +``` +serverless invoke --function functionName --stage dev --region us-east-1 --log +``` + +Just like the first example, but will also outputs logging information about your invocation. + +### Function invocation with data passing + +``` +serverless invoke --function functionName --stage dev --region us-east-1 --path lib/data.json +``` + +This example will pass the json data in the `lib/data.json` file (relative to the root of the service) while invoking +the specified/deployed function. diff --git a/lib/plugins/logs/logs.js b/lib/plugins/logs/logs.js new file mode 100644 index 000000000..b9e77074a --- /dev/null +++ b/lib/plugins/logs/logs.js @@ -0,0 +1,49 @@ +'use strict'; + +class Logs { + constructor(serverless) { + this.serverless = serverless; + + this.commands = { + logs: { + usage: 'Outputs the logs of a deployed function.', + lifecycleEvents: [ + 'logs', + ], + options: { + function: { + usage: 'The function name', + required: true, + shortcut: 'f', + }, + stage: { + usage: 'Stage of the service', + shortcut: 's', + }, + region: { + usage: 'Region of the service', + shortcut: 'r', + }, + tail: { + usage: 'Tail the log output', + shortcut: 't', + }, + duration: { + usage: 'Duration. Default: `5m`', + shortcut: 'd', + }, + filter: { + usage: 'A filter pattern', + shortcut: 'l', + }, + pollInterval: { + usage: 'Tail polling interval in milliseconds. Default: `1000`', + shortcut: 'i', + }, + }, + }, + }; + } +} + +module.exports = Logs; diff --git a/lib/plugins/logs/tests/logs.js b/lib/plugins/logs/tests/logs.js new file mode 100644 index 000000000..9ebc27ebc --- /dev/null +++ b/lib/plugins/logs/tests/logs.js @@ -0,0 +1,19 @@ +'use strict'; + +const expect = require('chai').expect; +const Invoke = require('../logs'); +const Serverless = require('../../../Serverless'); + +describe('Invoke', () => { + let invoke; + let serverless; + + beforeEach(() => { + serverless = new Serverless(); + invoke = new Invoke(serverless); + }); + + describe('#constructor()', () => { + it('should have commands', () => expect(invoke.commands).to.be.not.empty); + }); +});