deployer.js

// Include the AWS SDK
const AWS = require('aws-sdk')
const async = require('async')

const { Deploy } = require('./models')

// Create instances for ECR & CloudFormation
const ECR = new AWS.ECR()
const CloudFormation = new AWS.CloudFormation()

/**
 * 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) => {
        // 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)

        if (isStackMessage) {
          console.log('Tracking deploy status from msg:', msg)
          console.log('Current event object', JSON.stringify(event, null, 2))

          next()
        } else {
          console.log('Not tracking deploy status for msg:', msg)
          next()
        }
      }, err => {
        if (err) {
          // Reject on error
          reject(err)
        } else {
          // Resolve with event
          resolve(event)
        }
      })
    }
  )
}

/**
 * Deploy S3
 * @param {object} event
 * @param {object} deployment
 * @param {function} done
 */
const deployS3 = (event, deployment, done) => {
  console.log('Deploy to S3:', deployment)
  if (event.parsed.build.found) {
    done(new Error(`Skip deploy, build exists. Rebuilding`));
  } else {
    done(null, 'SUCCEEDED');
  }
}

/**
 * Deploy ECR
 * @param {object} event
 * @param {object} deployment
 * @param {function} done
 */
const deployEcr = (event, deployment, done) => {
  // Construct ECR parameters to search for the specified tag
  const ecrParams = {
    // Use repo as specified in the parsed service parameters
    repositoryName: process.env.ECR_REPO_NAME,
    imageIds: [
      {
        // Search for an image tag as specified in the parsed service parameters
        imageTag: deployment.tag
      }
    ]
  }

  // Check if repo & tag exist
  ECR.batchGetImage(ecrParams, (err, data) => {
    // Reject on error
    if (err) {
      return done(err)
    }

    // Skip deployment if tag does not exist in ECR
    if (data.images.length === 0) {
      console.log('Unable to find ECR image with params:', JSON.stringify(ecrParams))
      return done(null, 'FAILED')
    }

    // Ensure at least 1 image, and no failures
    // if (!data.images || data.images.length == 0 || data.failures.length > 0) {
    //   return done(data)
    // }

    // Describe the stack (verifies it exists, and can build parameter list)
    CloudFormation.describeStacks({
      // Get the stack name from the parsed service parameters
      StackName: deployment.stack
    }, (err, data) => {
      // Reject on error
      if (err) {
        console.warn(err)
        console.log('Unable to find CF Stack with name:', deployment.stack)
        return done(null, 'FAILED')
      }

      // This should be impossible, but ensure only a single stack is being returned
      if (data.Stacks.length > 1) {
        return done(new Error(`More than 1 stack matching name: ${deployment.stack}`))
      }

      // Filter current stack parameters except for "Tag"
      const params = data.Stacks[0].Parameters.map(p => {
        return {
          ParameterKey: p.ParameterKey,
          UsePreviousValue: true
        }
      }).filter(p => p.ParameterKey !== 'Tag')

      // Add the "Tag" parameter with the tag as specified by the parsed service parameters
      params.push({
        ParameterKey: 'Tag',
        ParameterValue: deployment.tag
      })

      // Update the CloudFormation stack with the reconstructed parameters
      CloudFormation.updateStack({
        // Get the stack name from the parsed service parameters
        StackName: deployment.stack,

        // Should include all previous parameters, and an updated "Tag" parameter
        Parameters: params,

        // Reuse the previous stack template
        UsePreviousTemplate: true,

        // Execute this stack update with the given role ARN
        //  - This is passed into the environment by serverless (see "serverless.yml")
        RoleARN: process.env.UPDATE_ROLE_ARN,

        // A stack that creates/changes IAM roles/policies/etc MUST provide this capability flag
        Capabilities: [ 'CAPABILITY_IAM' ],

        // Send stack change notifications to the SNS topic ARN
        //  - This is passed into the environment by serverless (see "serverless.yml")
        //  - This SNS topic is automatically hooked up to the "stack-change" function to send messages to Slack
        NotificationARNs: [ process.env.NOTIFY_SNS_ARN ]
      }, (err) => {
        if (err) {
          return done(err)
        }

        done(null, 'IN_PROGRESS')
      })
    })
  })
}

/**
 * Deploy build (from parsed deployments)
 * @param {object} event
 */
module.exports.deployBuild = (event) => {
  // Return a promise for the promise chain
  return new Promise(
    (resolve, reject) => {
      if (event.builds.prepared) {
        console.log('Event contains prepared build (new build). Skipping deployments ...')
        resolve(event)
        return
      }

      // Ensure the deployments have been parsed
      if (!event.parsed.deployments) {
        console.log('Event does not contain parsed deployment details.')
        resolve(event)
        return
      }

      // Debug logs for deployments being handled
      console.log('Parsed deployments', JSON.stringify(event.parsed.deployments, null, 2))
      console.log('Current event object', JSON.stringify(event, null, 2))

      // Loop through parsed deployments (async) and update CF stack(s)
      async.each(event.parsed.deployments, (deployment, next) => {
        const deployMethod = deployment.target === 's3' ? deployS3 : deployEcr

        deployMethod(event, deployment, (err, status) => {
          if (err) return next(err)

          const newDeploy = new Deploy(Object.assign({}, deployment, {
            service_name: process.env.SERVICE_NAME,
            deploy_status: status,
            deploy_timestamp: Date.now()
          }))

          newDeploy.saveNotify().then(data => {
            console.log('Saved deploy data:', data)

            next()
          }).catch(next)
        })
      }, err => {
        if (err) {
          console.warn(err)
        }
        resolve(event)
      })
    }
  )
}