// Include configuration
const config = require('./config')
const { Deploy } = require('./models')
// Include the AWS SDK
const AWS = require('aws-sdk')
const CloudWatchLogs = new AWS.CloudWatchLogs()
const CloudFormation = new AWS.CloudFormation()
// Include async and slack-notify
const https = require('https')
const fetch = require('node-fetch')
const async = require('async')
const slack = require('slack-notify')(config.slack.webhook_url)
/**
* Map the status of a stack change to a basic Slack message color
* @param {string} status
*/
const getStatusColor = (status) => {
switch (true) {
// If status ends in "_IN_PROGRESS", use color "warning" (yellow)
case /_?(IN_PROGRESS|STOPPED)$/.test(status):
return 'warning'
// If status ends in "_COMPLETE", use color "good" (green)
case /_?(COMPLETE|SUCCEEDED)$/.test(status):
return 'good'
// If status ends in "_FAILED", use color "danger" (red)
case /_?FAILED$/.test(status):
return 'danger'
// If status doesn't match any of these, use color "warning" (yellow)
default:
return 'warning'
}
}
/**
* Handle a stack change event (as published from SNS topic)
* @param {object} event
*/
module.exports.stackChange = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the SNS records have been parsed into messages
if (!event.parsed.messages || event.parsed.messages.length === 0) {
reject(new Error('No messages to be sent.'))
return
}
console.log('Messages:', JSON.stringify(event.parsed.messages, null, 2))
// Loop through all messages (async)
async.each(event.parsed.messages, (msg, next) => {
// Get the details for the stack of the current message
CloudFormation.describeStacks({
StackName: msg.StackName
}, (err, data) => {
// Reject on error
if (err) {
next(err)
return
}
// Filter stack parameters and attempt to find the "Tag" parameter
const findTag = data.Stacks[0].Parameters.filter(p => p.ParameterKey === 'Tag')
// Flag to determine whether this message is for parent stack or a stack resource
const isStackMessage = (msg.ResourceType === 'AWS::CloudFormation::Stack' && msg.LogicalResourceId === msg.StackName)
// Start the Slack message title with the stack's name
let title = msg.StackName
// If this is a message to a stacks resource (not the stack itself), append the resourceId to the title
if (!isStackMessage) {
title += '/' + msg.LogicalResourceId
}
// TODO: Show other info? Latest commit msg? Tag description? author/pusher/etc? hostHeader for service?
// If we successfully found a "Tag" parameter, append it as "Build #{Tag}" to the title
if (findTag.length === 1) {
title += ' (Build: ' + findTag[0].ParameterValue + ')'
}
// Decide whether or not to send a Slack notification
if (config.slack.notify_all_changes || isStackMessage) {
// Build Slack notification object (inherits from config.slack.defaults)
const notification = Object.assign({}, config.slack.defaults, {
attachments: [
{
title, // This evaluates to "title: title" since both sides are the same name
color: getStatusColor(msg.ResourceStatus),
fallback: `Change to stack: ${msg.StackName}`,
text: msg.ResourceStatus
}
]
})
const params = {
stack: msg.StackName,
deploy_status: 'IN_PROGRESS',
tag: findTag[0].ParameterValue
}
Deploy.scan(params).exec().then(deploys => {
let deploy = deploys[0]
if (!deploy) {
console.log(`Cannot find deploy to update: ${JSON.stringify(params)}`)
deploy = new Deploy(Object.assign({}, params, {
env: /(\-|_)prod(uction)?$/.test(msg.StackName) ? 'production' : 'staging',
service_name: process.env.SERVICE_NAME,
deploy_timestamp: Date.now()
}))
}
if (/_COMPLETE$/.test(msg.ResourceStatus)) {
deploy.deploy_status = msg.ResourceStatus === 'UPDATE_COMPLETE' ? 'SUCCEEDED' : 'FAILED'
}
console.log('Saving deploy', deploy)
deploy.save().then(resp => {
slack.send(notification, next)
}).catch(next)
})
} else {
// Not sending a slack notification for this update
next()
}
})
}, err => {
if (err) {
// Reject on error
reject(err)
} else {
// Resolve with event
resolve(event)
}
})
}
)
}
/**
* Update build details on change
* @param {object} event
*/
module.exports.buildChange = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the build change details have been parsed
if (!event.parsed.build || !event.parsed.build.model) {
reject(new Error('Event does not contain parsed build or model.'))
return
}
if (event.parsed.build.status !== 'SUCCEEDED') {
console.log('Skipping sync, build is:', event.parsed.build.status)
resolve(event)
return
}
event.parsed.build.model.syncImage().then(build => {
console.log('Sync image details for build', build)
event.parsed.build.model = build
resolve(event)
}).catch(reject)
}
)
}
/**
* Notify github of build change status
* @param {object} event
*/
module.exports.buildChangeNotifyBitbucket = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the build change details have been parsed
if (!event.parsed.build) {
reject(new Error('Event does not contain parsed build details.'))
return
}
if (!event.service.bitbucket || !event.service.bitbucket.consumer_key || !event.service.bitbucket.consumer_secret) {
console.log('Event service does not contain Bitbucket auth.')
resolve(event)
return
}
const data = {
'type': 'build',
'key': 'BOWTIE-CI',
'name': `Bowtie CI Build #${event.parsed.build.number}`,
'state': 'FAILED',
'description': 'The build errored!'
}
if (event.parsed.build.link) {
data.url = event.parsed.build.link
}
switch (event.parsed.build.status) {
case 'IN_PROGRESS':
data.state = 'INPROGRESS'
data.description = 'The build is in progress.'
break
case 'SUCCEEDED':
data.state = 'SUCCESSFUL'
data.description = 'The build finished!'
break
case 'FAILED':
data.state = 'FAILED'
data.description = 'The build failed!'
break
case 'STOPPED':
data.state = 'STOPPED'
data.description = 'The build is stopped.'
break
default:
data.state = 'FAILED'
data.description = 'Unknown build status!'
break
}
const body = JSON.stringify(data)
const basicAuth = Buffer.from(`${event.service.bitbucket.consumer_key}:${event.service.bitbucket.consumer_secret}`).toString('base64')
fetch('https://bitbucket.org/site/oauth2/access_token', {
method: 'POST',
headers: {
'Authorization': `Basic ${basicAuth}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials'
}).then(res => res.json()).then(authResp => {
fetch(`https://api.bitbucket.org/2.0/repositories/${event.parsed.build.source.slug}/commit/${event.parsed.build.source.version}/statuses/build`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authResp['access_token']}`,
'Content-Type': 'application/json'
},
body
}).then(res => res.json()).then(buildStatusResp => {
console.log(buildStatusResp)
resolve(event)
}).catch(reject)
}).catch(reject)
}
)
}
/**
* Notify github of build change status
*/
module.exports.buildChangeNotifyGithub = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the build change details have been parsed
if (!event.parsed.build) {
reject(new Error('Event does not contain parsed build details.'))
return
}
if (!event.service.github || !event.service.github.token) {
console.log('Event service does not contain GitHub token')
resolve(event)
return
}
const data = {
"state": "error",
"description": "The build errored!",
"context": "continuous-integration/bowtie"
}
if (event.parsed.build.link) {
data.target_url = event.parsed.build.link
}
switch(event.parsed.build.status) {
case 'IN_PROGRESS':
data.state = 'pending'
data.description = 'The build is pending.'
break
case 'SUCCEEDED':
data.state = 'success'
data.description = 'The build finished!'
break
case 'FAILED':
data.state = 'failure'
data.description = 'The build failed!'
break
case 'STOPPED':
data.state = 'pending'
data.description = 'The build is stopped.'
break
default:
data.state = 'error'
data.description = 'Unknown build status!'
break
}
const body = JSON.stringify(data)
const headers = {
'Authorization': 'token ' + event.service.github.token,
'User-Agent': 'BowTie-CI',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
const options = {
headers: headers,
port: 443,
method: 'POST',
hostname: 'api.github.com',
path: '/repos/' + event.parsed.build.source.slug + '/statuses/' + event.parsed.build.source.version
}
const req = https.request(options, (resp) => {
if (resp.statusCode == 201) {
resolve(event);
} else {
resp.on('data', responseData => {
console.log(responseData.toString())
reject(responseData)
})
}
})
req.on('error', reject)
req.write(body)
req.end()
}
)
}
/**
* Notify slack of build change status
* @param {object} event
*/
module.exports.buildChangeNotifySlack = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the build change details have been parsed
if (!event.parsed.build) {
reject(new Error('Event does not contain parsed build details.'))
return
}
let buildNumber = event.parsed.build.number
// let buildDuration = 0
let repoSlug = event.parsed.build.source.slug
let repoBranch = event.parsed.build.source.branch
let repoVersion = event.parsed.build.source.version
let repoLocation = event.parsed.build.source.location
let repoUrl = repoLocation.replace(/\.git$/, '')
let commitsPath = event.service.source.type === 'BITBUCKET' ? 'commits': 'commit'
if (event.parsed.build.link) {
buildNumber = `<${event.parsed.build.link}|${buildNumber}>`
}
repoSlug = `<${repoUrl}|${repoSlug}>`
repoBranch = `<${repoUrl}/tree/${repoBranch}|${repoBranch}>`
repoVersion = `<${repoUrl}/${commitsPath}/${repoVersion}|${repoVersion.substr(0, 7)}>`
// TODO: Include author, pusher, commit msg?
let msg = `Build #${buildNumber} (${repoVersion}) of ${repoSlug}@${repoBranch} *${event.parsed.build.status}*`
if (event.parsed.build.complete && event.parsed.build.duration) {
const minutes = Math.floor(event.parsed.build.duration / 60)
const seconds = event.parsed.build.duration % 60
msg += ` in ${minutes} min ${seconds} sec`
}
const notification = Object.assign({}, config.slack.defaults, {
attachments: [
{
color: getStatusColor(event.parsed.build.status),
fallback: `${process.env.SERVICE_NAME} build ${event.parsed.build.status}`,
text: msg,
mrkdwn_in: ['text']
}
]
})
if (event.parsed.build.complete && event.parsed.build.status === 'FAILED') {
const params = {
logGroupName: event.parsed.build.info.logs['group-name'],
logStreamName: event.parsed.build.info.logs['stream-name']
}
CloudWatchLogs.getLogEvents(params, (err, data) => {
let logOutput = data.events.map(e => e.message).join('')
if (logOutput.length > config.slack.text_max_length) {
logOutput = logOutput.substr(-1 * config.slack.text_max_length)
}
if (err) {
reject(err)
} else {
notification.attachments.push({
title: 'Build Log',
color: getStatusColor(event.parsed.build.status),
fallback: `${process.env.SERVICE_NAME} build log`,
text: logOutput
})
// Send the notification to slack (pass next as callback for async.each of messages)
slack.send(notification, (err) => {
if (err) {
reject(err)
} else {
resolve(event)
}
})
}
})
} else {
// Send the notification to slack (pass next as callback for async.each of messages)
slack.send(notification, (err) => {
if (err) {
reject(err)
} else {
resolve(event)
}
})
}
}
)
}
/**
* Notify Airbrake of deployment
* @param {object} event
*/
module.exports.deploymentNotifyAirbrake = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// No deployments to track
if (!event.parsed.deployments || event.parsed.deployments.length === 0) {
resolve(event)
return
}
// Airbrake is not configured for this service
if (!event.service.airbrake || !event.service.airbrake.id || !event.service.airbrake.key) {
resolve(event)
return
}
async.each(event.parsed.deployments, (deployment, next) => {
const data = {
'environment': deployment.env,
'username': deployment.user,
// TODO: Support github & bitbucket repo sources
'repository': event.service.source.base + '/' + deployment.repo,
'revision': deployment.rev,
'version': deployment.tag
}
const body = JSON.stringify(data)
const headers = {
'Content-Type': 'application/json'
}
const options = {
headers: headers,
port: 443,
method: 'POST',
hostname: 'airbrake.io',
path: '/api/v4/projects/' + event.service.airbrake.id + '/deploys?key=' + event.service.airbrake.key
}
const req = https.request(options, (resp) => {
if (resp.statusCode === 201) {
next()
} else {
resp.on('data', responseData => {
console.log(responseData.toString())
next(responseData)
})
}
})
req.on('error', next)
req.write(body)
req.end()
}, err => {
if (err) {
reject(err)
} else {
resolve(event)
}
})
}
)
}
/**
* Notify slack of action failure
* @param {Error} failure
*/
module.exports.actionFailureNotifySlack = (failure) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
console.log(failure)
console.log(JSON.stringify(failure, null, 2))
let msg = failure.message.toString() || 'Unknown Failure'
if (failure.stack) {
msg += `\n${failure.stack}`
}
const notification = Object.assign({}, config.slack.defaults, {
text: '```' + JSON.stringify(failure, null, 2) + '```',
attachments: [
{
title: 'Something Failed!',
color: 'danger',
fallback: 'Action failed',
text: msg
}
]
})
// Send the notification to slack (pass next as callback for async.each of messages)
slack.send(notification, (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
}
)
}