notifier.js

// 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()
        }
      })
    }
  )
}