diff --git a/.albot.json.template b/.albot.json.template index 68e4481..5c28859 100644 --- a/.albot.json.template +++ b/.albot.json.template @@ -23,5 +23,10 @@ }, "changelog": { "gistId": "" + }, + "amazon": { + "key": "", + "secret": "", + "region": "" } } diff --git a/README.md b/README.md index 7f6a193..2beb572 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ You will just need Coffeescript pulls [ | without | with | recent [] | last [ | ]] List all Pull Requests of the organisation deploy [ | ] [] Deploy your projects with the configured command - changelog [ | ] | | ["save"] List changelog for a given PR, period, range + changelog [ | ] | | [save] List changelog for a given PR, period, range + amazon [ instances [ with ] ] Display various informations about your Amazon infrastructure help Display a list of available commands server Start albot to listen on Hipchat instead of the command line @@ -146,6 +147,23 @@ Available in all flavour $ albot changelog webapp between 43..45 save +### Amazon + +Display substantial informations about your Amazon infrastructure. +Like the list of your EC2 instances. + + $ albot amazon instances + +Will display all the instances available on your account. + +Interestingly, if you have DNS configured in Route53, this route will be displayed instead of the Public DNS. + +If some of your instances are behind an ElasticLoadBalancer, more details will be provided for it. + +You can, as always, filter the result list + + $ albot amazon instances with live + ### Server Albot can also be use as an Hipchat bot. diff --git a/TODO b/TODO deleted file mode 100644 index b350cf7..0000000 --- a/TODO +++ /dev/null @@ -1,24 +0,0 @@ -Improvement: -- Error handling (Again) -- Better currying of the fallback function in commands -- Clean code (More Async) -- Verbose mode (Maybe to test console.log?) (Winston !) -- Display the ID of a PR in Pulls -- Status of commits in Changelog -- More human redeable queries -- Save changelog into a real file in the repo - -Maybe -- Better Link customisation when printing (template) -- Inspect more than the first page of PRs -- Add usage statistics for each commands - -Commands: -- Github status: https://status.github.com/api -- Tagging -- Launch Jenkins build -- Show deployed version -- Retest PR -- shipit (To mark the PR as being reviewed by someone) -- Amazon EC2 listing -- Varnish logs diff --git a/lib/amazon.coffee b/lib/amazon.coffee new file mode 100644 index 0000000..3a142de --- /dev/null +++ b/lib/amazon.coffee @@ -0,0 +1,119 @@ +Configuration = require './configuration' +_ = require('underscore')._ +_.str = require 'underscore.string' +_.mixin _.str.exports() + +Async = require 'async' + +Utils = require './utils' +AwsHelpers = require './aws_helpers' + +amazon = (fallback, keyword, filter, filterValue) -> + + Route53 = new Configuration.Amazon.aws.Route53() + Ec2 = new Configuration.Amazon.aws.EC2() + Elb = new Configuration.Amazon.aws.ELB() + + params = {} + + if (keyword == 'instances') + AwsHelpers.getAllRecordSets Route53, (err, recordSets) -> + if (not recordSets?) + Utils.fallback_printError fallback, err + else + dnsRoutePairs = mappingDnsWithRoute(recordSets) + + findInstancesBehindLoadBalancer Elb, dnsRoutePairs, (err, idInstances) -> + if (not idInstances?) + Utils.fallback_printError fallback, err + else + if (filter == 'with') + display fallback, Ec2, params, dnsRoutePairs, idInstances, filterValue + else + display fallback, Ec2, params, dnsRoutePairs, idInstances, filter + + +display = (fallback, Ec2, params, dnsRoutePairs, idInstances, filterValue) -> + AwsHelpers.getInstancesByParams Ec2, params, (err, results) -> + list = _.map results, (instance) -> + + tag = _.findWhere instance.Tags, {"Key": "Name"} + security = instance.SecurityGroups[0] + role = if instance.IamInstanceProfile? then " / Role: " + instance.IamInstanceProfile.Arn.split('/')[1] else "" + route = _.findWhere dnsRoutePairs, {"Dns": instance.PublicDnsName} + title = + if (route?) + route.Route + else if instance.PublicDnsName + instance.PublicDnsName + else + "No route or public dns" + + behinds = _.findWhere idInstances, {"Id": instance.InstanceId} + lb = if behinds? and behinds.LoadBalancer? then " - behind: #{behinds.LoadBalancer.LoadBalancerName}" else "" + + { + title: title + #TODO: Should remove the trailing dot + url: "http://#{title}" + infos: if tag? then tag.Value + ' / ' + instance.InstanceType else instance.InstanceType + comments: if security? then "Security: #{security.GroupName}#{role}" + status: instance.State.Name == 'running' + tails: if behinds? then _.map _.pluck(behinds.Routes, 'Route'), (route) -> "via #{route}#{lb}" + } + + Utils.fallback_printList fallback, list, (list) -> + if (filterValue?) + _.filter list, (o) -> + stuff = o.title + o.infos + o.comments + stuff += _.reduce o.tails, (memo, tail) -> + memo.concat(tail) + , [] + + _.str.include(stuff, filterValue) + else + list + +mappingDnsWithRoute = (recordSets) -> + _.reduce recordSets, (memo, recordSet) -> + if (not _.isEmpty(recordSet.ResourceRecords)) + memo.concat _.map recordSet.ResourceRecords, (record) -> + { Dns: record.Value, Route: recordSet.Name } + else + memo.concat [ + { Dns: recordSet.AliasTarget.DNSName, Route: recordSet.Name } + ] + , [] + +filterByUrl = (dnsRoutePairs, url) -> + if (url?) + _.filter dnsRoutePairs, (name) -> name.Route.indexOf(url) > -1 + else + dnsRoutePairs + +findInstancesBehindLoadBalancer = (Elb, routes, callback) -> + names = _.reduce routes, (memo, dnsRoutePair) -> + match = dnsRoutePair.Dns.match("^(.*lb)-") + + if match then memo.concat(match[1]) else memo + , [] + + AwsHelpers.getLoadBalancersByNames Elb, _.uniq(names), (err, lbDescriptions) -> + if (not lbDescriptions?) + callback err + else + Async.reduce lbDescriptions, [], (memo, description, cb) -> + cb null, memo.concat _.map description.Instances, (instance) -> + { + Id: instance.InstanceId, + LoadBalancer: description, + Routes: _.where(routes, {"Dns": description.DNSName + "."}) + } + , (err, idInstances) -> + callback null, idInstances + +module.exports = { + name: "Amazon", + description: "[ instances [ with -term- ] ] Display various informations about your Amazon infrastructure", + action: amazon +} diff --git a/lib/aws_helpers.coffee b/lib/aws_helpers.coffee new file mode 100644 index 0000000..40accac --- /dev/null +++ b/lib/aws_helpers.coffee @@ -0,0 +1,71 @@ +Configuration = require './configuration' +_ = require('underscore')._ + +Async = require 'async' + +prepareFilters = (name, value, sensitive) -> + if (_.isString(value) or _.isArray(value)) + if (sensitive) + value = [ + "\*" + value.toUpperCase() + "\*", + "\*" + value.toLowerCase() + "\*" + ] + else if (_.isString(value)) + value = ["\*" + value + "\*"] + + {"Filters": [{ + "Name": name, + "Values": value + }]} + +#TODO: More than 100 instances +getInstancesByParams = (Ec2, params, callback) -> + Ec2.describeInstances params, (err, data) -> + if (not data?) + callback(err) + else + callback null, _.reduceRight data.Reservations, (memo, reservation) -> + memo.concat(reservation.Instances) + , [] + +getAllRecordSets = (Route53, callback) -> + Route53.listHostedZones {}, (err, hostedZones) -> + if (not hostedZones?) + callback(err) + else + Async.concat hostedZones.HostedZones, (hz, cb) -> + getAllRecordSetsAcc Route53, hz, [], null, null, cb + , callback + +getAllRecordSetsAcc = (Route53, hz, acc, recordName, recordType, callback) -> + if (recordName? and recordType?) + params = {"HostedZoneId": hz.Id, "StartRecordName": recordName, "StartRecordType": recordType} + else + params = {"HostedZoneId": hz.Id} + + Route53.listResourceRecordSets params, (err, records) -> + if (not records?) + callback(err, []) + else + all = acc.concat records.ResourceRecordSets + + if (records.IsTruncated) + getAllRecordSetsAcc(Route53, hz, all, records.NextRecordName, records.NextRecordType, callback) + else + callback(null, all) + +getLoadBalancersByNames = (Elb, names, callback) -> + params = {"LoadBalancerNames": names} + + Elb.describeLoadBalancers params, (err, loadBalancers) -> + if (not loadBalancers?) + callback err + else + callback null, loadBalancers.LoadBalancerDescriptions + +module.exports = { + prepareFilters: prepareFilters, + getInstancesByParams: getInstancesByParams, + getAllRecordSets: getAllRecordSets, + getLoadBalancersByNames: getLoadBalancersByNames +} diff --git a/lib/changelog.coffee b/lib/changelog.coffee index 1cbbf78..9227600 100644 --- a/lib/changelog.coffee +++ b/lib/changelog.coffee @@ -126,6 +126,6 @@ module.exports = { name: "Changelog", description: "-project- [ | -alias-] -pr- -number- | -since- -number- -period- | -between- -tag-range- - [\"save\"] List changelog for a given PR, period, range ", + [save] List changelog for a given PR, period, range ", action: changelog } diff --git a/lib/commands.coffee b/lib/commands.coffee index 1afe1af..4da68aa 100644 --- a/lib/commands.coffee +++ b/lib/commands.coffee @@ -20,6 +20,7 @@ list = { pulls: require('./pulls'), deploy: require('./deploy'), changelog: require('./changelog'), + amazon: require('./amazon'), help: { name: "Help" description: "Display a list of available commands", diff --git a/lib/configuration.coffee b/lib/configuration.coffee index ba59b30..c9bbc37 100644 --- a/lib/configuration.coffee +++ b/lib/configuration.coffee @@ -4,6 +4,7 @@ Path = require 'path' HipchatApi = require 'hipchat' GithubApi = require 'github' Winston = require 'winston' +AWS = require 'aws-sdk' userHome = () -> process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE @@ -35,6 +36,11 @@ Nconf "deploy": { "args": [], "env": [] + }, + "amazon": { + "key": "", + "secret": "", + "region": "" } } @@ -55,6 +61,23 @@ initLogger = @logger +initAws = + (aws) -> + if (aws?) + @aws = aws + else + credentials = + { + accessKeyId: Nconf.get("amazon").key, + secretAccessKey: Nconf.get("amazon").secret, + region: Nconf.get("amazon").region + } + + AWS.config.update credentials + @aws = AWS + + @aws + module.exports = Nconf: Nconf, Nickname: Nconf.get('nickname'), @@ -69,8 +92,12 @@ module.exports = Channel: Nconf.get('hipchat').channel, Frequency: Nconf.get('hipchat').frequency }, + Amazon: { + initAws: initAws, + aws: @aws || initAws() + }, Winston: { initLogger: initLogger, - logger: @logger || initLogger + logger: @logger || initLogger() }, Version: JSON.parse(Fs.readFileSync(Path.resolve(__dirname, '../package.json'), 'utf8')).version diff --git a/lib/server.coffee b/lib/server.coffee index a71c98b..51c67e9 100644 --- a/lib/server.coffee +++ b/lib/server.coffee @@ -36,13 +36,13 @@ server = (frequency, testCallback) -> freq = if _.isString(frequency) then frequency else Hipchat.Frequency Hipchat.Rooms.history Hipchat.Channel, (error, lines) -> - if (error?) then console.log("An error occured while fetching history: #{JSON.stringify(error)}") + if (error?) then Winston.logger.error("An error occured while fetching history: #{JSON.stringify(error)}") else if(lines) Cache.store(lines.messages) intervalId = setInterval () -> Hipchat.Rooms.history Hipchat.Channel, (error, lines) -> - if (error?) then console.log("An error occured while fetching history: #{JSON.stringify(error)}") + if (error?) then Winston.logger.error("An error occured while fetching history: #{JSON.stringify(error)}") else if (lines) Async.each lines.messages, (line, cb) -> if (not Cache.cached(line)) diff --git a/lib/utils.coffee b/lib/utils.coffee index 13a6c89..2da832c 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -17,19 +17,24 @@ status_color = (status) -> else "yellow" -format_term = (title, url, infos, comments, status, avatar) -> +format_term = (title, url, infos, comments, status, avatar, tails) -> icon = status_icon(status) color = status_color(status) text = "" text += "#{Styled(color, icon)} " if icon? and color? text += "#{title}" - text += " - #{infos}" if infos? - text += " - #{comments}" if comments? + text += " - #{Styled('bold', infos)}" if infos? + text += " - #{Styled('italic', comments)}" if comments? + if (tails?) + for tail in tails + do (tail) -> + text += "\n\t ↳ #{tail}" + text # TODO: Use templating -format_html = (title, url, infos, comments, status, avatar) -> +format_html = (title, url, infos, comments, status, avatar, tails) -> html = "" html += "#{status_icon(status)} " if (avatar?) and Configuration.Github.Gravatar @@ -39,6 +44,11 @@ format_html = (title, url, infos, comments, status, avatar) -> html += "" if url? html += " - #{infos}" if infos? html += " - #{comments}" if comments? + if (tails?) + for tail in tails + do (tail) -> + html += "
  ↳ #{tail}" + html print = (o, callback) -> @@ -48,6 +58,8 @@ print = (o, callback) -> o['infos'], o['comments'], o['status'] + o['avatar'] + o['tails'] ) if (callback? and _.isFunction(callback)) then callback(null) @@ -61,6 +73,7 @@ render = (o, callback) -> o['comments'], o['status'], o['avatar'] + o['tails'] ), { message_format: "html", color: status_color(o['status']) @@ -87,7 +100,8 @@ fallback_printList = (fallback, list, filter) -> infos: item.infos, comments: item.comments, status: item.status, - avatar: item.avatar + avatar: item.avatar, + tails: item.tails }, callback , (error) -> if (error?) diff --git a/package.json b/package.json index 83f287c..f92c414 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "underscore.string": "~2.3.1", "mkdirp": "~0.3.5", "temp": "~0.5.0", + "aws-sdk": "~1.3.1", "winston": "~0.7.2" }, "devDependencies": { @@ -43,7 +44,8 @@ "grunt-release": "~0.3.3", "grunt-coffeelint": "~0.0.7", "grunt-contrib-clean": "~0.4.1", - "matchdep": "~0.1.2" + "matchdep": "~0.1.2", + "schmock": "0.0.5" }, "engines": { "node": ">=0.10.x", diff --git a/test/aws_helpers.coffee b/test/aws_helpers.coffee new file mode 100644 index 0000000..42ec39b --- /dev/null +++ b/test/aws_helpers.coffee @@ -0,0 +1,23 @@ +Require = require('covershot').require.bind(null, require) + +should = require('chai').should() + +AwsHelpers = Require '../lib/aws_helpers' + +describe 'AwsHelpers', () -> + describe '#prepareFilters()', () -> + it 'should prepare the right Filters for String', () -> + filters = AwsHelpers.prepareFilters 'tag:Name', 'dev-albot-1' + filters.Filters[0].Name.should.equal 'tag:Name' + filters.Filters[0].Values[0].should.equal '*dev-albot-1*' + + it 'should prepare the right Filters for String and case sensitive', () -> + filters = AwsHelpers.prepareFilters 'tag:Name', 'dev-albot-2', true + filters.Filters[0].Name.should.equal 'tag:Name' + filters.Filters[0].Values[0].should.equal '*DEV-ALBOT-2*' + filters.Filters[0].Values[1].should.equal '*dev-albot-2*' + + it 'should prepare the right Filters for Array', () -> + filters = AwsHelpers.prepareFilters 'tag:Name', ['dev-albot-3'] + filters.Filters[0].Name.should.equal 'tag:Name' + filters.Filters[0].Values[0].should.equal 'dev-albot-3' diff --git a/test/commands/amazon.coffee b/test/commands/amazon.coffee new file mode 100644 index 0000000..96919db --- /dev/null +++ b/test/commands/amazon.coffee @@ -0,0 +1,115 @@ +Require = require('covershot').require.bind(null, require) + +should = require('chai').should() + +Configuration = Require '../../lib/configuration' +Commands = Require '../../lib/commands' + +Schmock = Require 'schmock' +Moment = Require 'moment' + +describe 'Commands', () -> + describe '#amazon()', () -> + before () -> + Schmock.loud() + MockAws = Schmock.mock('AmazonAws') + + MockAws.when('Route53').return { + listHostedZones: (params, cb) -> + cb null, { HostedZones: [ + { Id: 'hz_one' } + ]} + listResourceRecordSets: (params, cb) -> + should.exist params + params.HostedZoneId.should.equal 'hz_one' + + cb null, { ResourceRecordSets: [{ + ResourceRecords: [ + { + Value: 'ec2-12345-dns-name.albot.com', + } + ], Name: 'albot.github.com' + + }, { + ResourceRecords: [], + AliasTarget: { DNSName: 'dev-albot-lb-test.albot.com.' }, + Name: 'www.albot.com' + #TODO: More than one page + }], IsTruncated: false } + } + MockAws.when('EC2').return { + describeInstances: (params, cb) -> + cb null, { Reservations: [{ + Instances: [{ + InstanceId: 'live-albot-ec2.github.com', + InstanceType: 'medium' + PublicDnsName: 'ec2-12345-dns-name.albot.com', + Tags: [{ Key: 'Name', Value: 'live-albot-1' }], + SecurityGroups: [{ 'GroupName': 'sg-albot' }], + IamInstanceProfile: { + Arn: 'blabla/live-role' + }, + State: { Name:'running' } + }, { + InstanceId: 'dev-albot-ec2.github.com', + InstanceType: 'small' + PublicDnsName: 'ec2-6789-dns-name.albot.com', + Tags: [{ Key: 'Name', Value: 'dev-albot-1' }], + SecurityGroups: [{ 'GroupName': 'sg-albot' }], + IamInstanceProfile: { + Arn: 'blabla/dev-role' + }, + State: { Name:'terminated' } + }] + }] + } + } + MockAws.when('ELB').return { + describeLoadBalancers: (params, cb) -> + should.exist params + params.LoadBalancerNames[0].should.equal 'dev-albot-lb' + + cb null, { LoadBalancerDescriptions: [{ + Instances: [{ InstanceId: 'dev-albot-ec2.github.com' }], + DNSName: 'dev-albot-lb-test.albot.com', + LoadBalancerName: 'dev-albot-lb' + }] + } + } + + Configuration.Amazon.initAws(MockAws) + + it 'should return all the instances', (done) -> + count = 0 + Commands.amazon.action (object, cb) -> + if (count is 0) + object.title.should.equal "albot.github.com" + object.url.should.equal "http://albot.github.com" + object.infos.should.equal "live-albot-1 / medium" + object.comments.should.equal "Security: sg-albot / Role: live-role" + object.status.should.equal true + should.not.exist object.avatar + should.not.exist object.tails + else + object.title.should.equal "ec2-6789-dns-name.albot.com" + object.url.should.equal "http://ec2-6789-dns-name.albot.com" + object.infos.should.equal "dev-albot-1 / small" + object.comments.should.equal "Security: sg-albot / Role: dev-role" + object.status.should.equal false + object.tails[0].should.equal "via www.albot.com - behind: dev-albot-lb" + count += 1 + if (count is 2) then done() + cb() + , 'instances' + + it 'should return instances with a filter', (done) -> + Commands.amazon.action (object, cb) -> + object.title.should.equal "ec2-6789-dns-name.albot.com" + object.url.should.equal "http://ec2-6789-dns-name.albot.com" + object.infos.should.equal "dev-albot-1 / small" + object.comments.should.equal "Security: sg-albot / Role: dev-role" + object.status.should.equal false + object.tails[0].should.equal "via www.albot.com - behind: dev-albot-lb" + done() + cb() + , 'instances', 'with', 'www.albot.com' diff --git a/test/utils.coffee b/test/utils.coffee index f2ac61c..d8c1dff 100644 --- a/test/utils.coffee +++ b/test/utils.coffee @@ -9,10 +9,10 @@ describe 'Utils', () -> describe '#format_term()', () -> it 'should have styled status', () -> ok = Utils.format_term("title", null, "infos", "comments", true) - ok.should.equal "\u001b[32m✓\u001b[0m title - infos - comments" + ok.should.equal "\u001b[32m✓\u001b[0m title - \u001b[1minfos\u001b[0m - \u001b[3mcomments\u001b[0m" nok = Utils.format_term("title", null, "infos", "comments", false) - nok.should.equal "\u001b[31m✘\u001b[0m title - infos - comments" + nok.should.equal "\u001b[31m✘\u001b[0m title - \u001b[1minfos\u001b[0m - \u001b[3mcomments\u001b[0m" it 'should have only title mandatory', () -> text = Utils.format_term("title") @@ -34,6 +34,10 @@ describe 'Utils', () -> test = Utils.format_html("title", "http://google.fr", "infos", "comments", false, "205e460b479e2e5b48aec07710c08d50") test.should.equal "✘ - title - infos - comments" + it 'should display tails as multi-line', () -> + test = Utils.format_html("title", null, "infos", "comments", false, null, ["this is not a tail recursion"]) + test.should.equal "✘ title - infos - comments
  ↳ this is not a tail recursion" + describe '#render()', () -> it 'should send a message to the Hipchat API', () -> nock = Nock('http://api.hipchat.com')