@bowtie/sls
Version:
Serverless helpers & utilities
713 lines (597 loc) • 26.2 kB
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>notifier.js - Documentation</title>
<script src="scripts/prettify/prettify.js"></script>
<script src="scripts/prettify/lang-css.js"></script>
<!--[if lt IE 9]>
<script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
<link type="text/css" rel="stylesheet" href="https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css">
<link type="text/css" rel="stylesheet" href="styles/prettify-tomorrow.css">
<link type="text/css" rel="stylesheet" href="styles/jsdoc-default.css">
</head>
<body>
<input type="checkbox" id="nav-trigger" class="nav-trigger" />
<label for="nav-trigger" class="navicon-button x">
<div class="navicon"></div>
</label>
<label for="nav-trigger" class="overlay"></label>
<nav>
<li class="nav-link nav-home-link"><a href="index.html">Home</a></li><li class="nav-heading"><a href="global.html">Globals</a></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#actionFailureNotifySlack">actionFailureNotifySlack</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#bitbucketWebhook">bitbucketWebhook</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#buildChange">buildChange</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#buildChangeNotifyBitbucket">buildChangeNotifyBitbucket</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#buildChangeNotifyGithub">buildChangeNotifyGithub</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#buildChangeNotifySlack">buildChangeNotifySlack</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#decodeBody">decodeBody</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#deployBuild">deployBuild</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#deployEcr">deployEcr</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#deploymentNotifyAirbrake">deploymentNotifyAirbrake</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#deployments">deployments</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#deployS3">deployS3</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#describeStack">describeStack</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#findClusterName">findClusterName</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#findClusterStack">findClusterStack</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#findMigrationTask">findMigrationTask</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#getStatusColor">getStatusColor</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#githubWebhook">githubWebhook</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#init">init</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#initMigration">initMigration</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#parseBody">parseBody</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#parsePayload">parsePayload</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#prepareBuild">prepareBuild</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#runMigration">runMigration</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#slackCommand">slackCommand</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#slackResponse">slackResponse</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#stackChange">stackChange</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#startBuild">startBuild</a></span></li><li class="nav-item"><span class="nav-item-type type-function">F</span><span class="nav-item-name"><a href="global.html#trackBuild">trackBuild</a></span></li>
</nav>
<div id="main">
<h1 class="page-title">notifier.js</h1>
<section>
<article>
<pre class="prettyprint source linenums"><code>// Include configuration
const config = require('./config')
const { Deploy } = require('./models')
// Include the AWS SDK
const AWS = require('aws-sdk')
const SES = new AWS.SES()
const CloudWatchLogs = new AWS.CloudWatchLogs()
const CloudFormation = new AWS.CloudFormation()
// Include async and slack-notify
const fs = require('fs-extra')
const path = require('path')
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.scanAll(params).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: event.helpers.envGetAlias(msg.StackName),
release: event.helpers.tagIsRelease(params.tag),
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.saveNotify().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) => {
console.log(JSON.stringify(event, null, 2));
// 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'
let branchesPath = event.service.source.type === 'BITBUCKET' ? 'branch': 'tree'
if (event.parsed.build.link) {
buildNumber = `<${event.parsed.build.link}|${buildNumber}>`
}
repoSlug = `<${repoUrl}|${repoSlug}>`
repoBranch = `<${repoUrl}/${branchesPath}/${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()
}
})
}
)
}
module.exports.sendEmail = (event) => {
const Handlebars = require("handlebars");
// const template = Handlebars.compile("Name: {{name}}");
// console.log(template({ name: "Nils" }));
return new Promise(
(resolve, reject) => {
const { SEND_EMAIL_FROM, SEND_EMAIL_CONF } = process.env;
if (!SEND_EMAIL_FROM || SEND_EMAIL_FROM.trim() === '') {
return reject(new Error('No send email configured'));
}
if (!event.parsed || !event.parsed.body || !event.parsed.body.to || !event.parsed.body.subject) {
return reject(new Error('Missing/invalid payload'));
}
const { to, subject, message, data, view = 'example' } = event.parsed.body;
const viewDir = path.join(__dirname, 'views');
const viewHtml = `${view.replace(/\.(html|hbs)/g, '')}.html.hbs`;
const viewPath = path.join(viewDir, viewHtml);
if (!fs.existsSync(viewPath)) {
return reject(new Error(`Missing/invalid view view: '${view}'`));
}
const template = Handlebars.compile(fs.readFileSync(viewPath).toString());
const htmlData = template({
subject,
message,
data: JSON.stringify(data, null, 2)
});
// let renderedHtml = htmlBody;
// Object.keys(htmlData).forEach(key => {
// const keyRegex = new RegExp(`\\[${key.toUpperCase()}\\]`, 'g');
// renderedHtml = renderedHtml.replace(keyRegex, htmlData[key]);
// })
const sesParams = {
// ConfigurationSetName: 'example',
Destination: {
ToAddresses: [to],
},
Message: {
Body: {
Html: {
Charset: 'UTF-8',
Data: htmlData,
},
},
Subject: {
Charset: 'UTF-8',
Data: subject,
},
},
ReplyToAddresses: [SEND_EMAIL_FROM],
Source: SEND_EMAIL_FROM,
};
// if (SEND_EMAIL_CONF && SEND_EMAIL_CONF.trim() !== '') {
// sesParams.ConfigurationSetName = SEND_EMAIL_CONF;
// }
console.log(sesParams);
SES.sendEmail(sesParams, (err, response) => {
if (err) {
return reject(err);
}
console.log(response);
resolve({ message: 'Sent', sesParams, response });
});
}
);
};
</code></pre>
</article>
</section>
</div>
<br class="clear">
<footer>
Generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 3.6.4</a> on Mon Jun 22 2020 11:46:50 GMT-0600 (Mountain Daylight Time) using the Minami theme.
</footer>
<script>prettyPrint();</script>
<script src="scripts/linenumber.js"></script>
</body>
</html>