builder.js

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

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

const ECR = new AWS.ECR()
const CodeBuild = new AWS.CodeBuild()

/**
 * Count number of current builds (use count+1 for next build number)
 * @param {object} event
 */
module.exports.countBuilds = (event) => {
  return new Promise(
    (resolve, reject) => {
      Build.scan().exec().then(data => {
        event.builds.count = data.count

        resolve(event)
      }).catch(reject)
    }
  )
}

/**
 * Prepare build from event body
 * @param {object} event
 */
module.exports.prepareBuild = (event) => {
  return new Promise(
    (resolve, reject) => {
      // Ensure the body has been parsed from the webhook
      if (!event.parsed.body) {
        reject(new Error('Event is missing parsed body from webhook.'))
        return
      }

      // Use typeof !== 'number' since count could be 0 and evaluate to false...
      if (typeof event.builds.count !== 'number') {
        reject(new Error('Build count has not been initialized.'))
        return
      }

      // TODO: Enable github & bitbucket support
      if (event.service.source.type === 'GITHUB' && !event.parsed.body.head_commit) {
        console.log('No head_commit in github webhook body. Nothing to be built...')
        resolve(event)
        return
      }

      // Resolve gracefully if push event contains no changes
      if (event.service.source.type === 'BITBUCKET' && event.parsed.body.push.changes.length === 0) {
        console.log('No changes in webhook body. Nothing to be built...')
        resolve(event)
        return
      }

      let release = null
      let build_author, build_sender, build_pusher, source_version, source_branch = 'Unknown'

      if (event.service.source.type === 'GITHUB') {
        build_sender = event.parsed.body.sender.login
        build_author = event.parsed.body.head_commit.author.name || event.parsed.body.head_commit.author.username
        build_pusher = event.parsed.body.pusher.name

        source_version = event.parsed.body.head_commit.id
        source_branch = event.parsed.body.ref.split('/').splice(2).join('/')

        if (event.parsed.body.ref.indexOf('refs/tags') !== -1) {
          release = source_branch
        }
      } else if (event.service.source.type === 'BITBUCKET') {
        // Pull actor (user pushing) from event payload
        const actor = event.parsed.body.actor

        // Pull single change from changes list
        const change = event.parsed.body.push.changes[0]

        const change_info = change.new

        if (!change_info) {
          console.log('Unable to detect change info from payload. Old branch data from merge?')
          resolve(event)
          return
        }

        const { author } = change_info.target

        let actor_name = 'Unknown'
        let author_name = 'Unknown'

        if (actor && actor.nickname) {
          actor_name = actor.nickname
        }

        if (author && author.user && author.user.nickname) {
          author_name = author.user.nickname
        } else if (author && author.raw) {
          author_name = author.raw
        }

        build_sender = actor_name
        build_author = author_name
        build_pusher = author_name

        source_version =  change_info.target.hash
        source_branch = change_info.name

        if (change_info.type === 'tag') {
          release = source_branch
        }
      }

      // Prepare build data (flat object, persisted to DynamoDB)
      event.builds.prepared = {
        service_name: process.env.SERVICE_NAME,
        build_status: 'CREATED',
        // [HIGH] TODO: Set build_number from CodeBuild (now available)
        build_number: event.builds.count + 1,
        build_timestamp: Date.now(),
        build_author,
        build_sender,
        build_pusher,
        source_version,
        source_branch,
        release
      }

      console.log('Prepared build:', event.builds.prepared)

      // Restructure prepared build object
      event.parsed.build = {
        number: event.builds.prepared.build_number,
        author: event.builds.prepared.build_author,
        sender: event.builds.prepared.build_sender,
        pusher: event.builds.prepared.build_pusher,
        release: event.builds.prepared.release,
        timestamp: event.builds.prepared.build_timestamp,
        source: {
          version: event.builds.prepared.source_version,
          branch: event.builds.prepared.source_branch
        }
      }

      console.log('Parsed build:', event.parsed.build)

      // Build ECR params to search for change version tag (commit SHA)
      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: event.parsed.build.source.version // commit SHA (hash)
          }
        ]
      }

      console.log('Check ECR using params:', ecrParams)

      // Search ECR for existing version tag (using commit SHA)
      ECR.batchGetImage(ecrParams, (err, data) => {
        // Log on error
        if (err) {
          console.log(err)
        // Source version tag already exists!
        } else if (data.images.length === 1) {
          // Construct new tag from parsed build details (release priority, otherwise build number)
          // const newTag = event.parsed.build.release || event.parsed.build.number

          // TODO: Include PR number as tag? Other? Custom from commits?
          const tags = [
            // Sanitize source branch name (replace / with -)
            event.parsed.build.source.branch.replace(/\//g, '-')
          ]

          // Add release tag (if specified, and different than already added branch)
          if (event.parsed.build.release && !tags.includes(event.parsed.build.release)) {
            tags.push(event.parsed.build.release)
          } else if (!event.parsed.build.release) {
            tags.push(event.parsed.build.number)
          }

          console.log('Existing images for SHA, add tags', tags, data.images)

          // Loop through tags to add to existing image (only applies to tagging existing images)
          async.each(tags, (tag, next) => {
            // Update existing ECR image tag with current tag
            ECR.putImage({
              imageManifest: data.images[0].imageManifest,
              repositoryName: process.env.ECR_REPO_NAME,
              imageTag: tag.toString()
            }, next)
          }, (err) => {
            // Reject on error
            if (err) {
              console.log('Failed to put new tag for ECR image', err)
            }

            // Already built & tagged, delete prepared build data
            delete event.builds.prepared

            // Update parsed build status, assume succeeded since tag already exists
            event.parsed.build.status = 'SUCCEEDED'

            resolve(event)
          })

        // Source version tag does not exist, insert build details into DynamoDB
        } else {
          const newBuild = new Build(event.builds.prepared)

          newBuild.save().then(data => {
            console.log('Saved Build:', data)
            Object.assign(event.builds.prepared, data)

            resolve(event)
          }).then(reject)
        }
      })
    }
  )
}

/**
 * Start build using CodeBuild Project (if build has been prepared)
 * @param {object} event
 */
module.exports.startBuild = (event) => {
  return new Promise(
    (resolve, reject) => {
      // Ensure a build has been prepared
      if (!event.builds.prepared) {
        console.log('No build is prepared, not starting anything...')

        // Resolve gracefully, allows for direct deployment of duplicate build versions
        resolve(event)
        return
      }

      // Construct CodeBuild params
      const buildParams = {
        projectName: process.env.BUILD_PROJECT_NAME, /* required */
        sourceVersion: event.builds.prepared.source_version, // commit SHA (hash)
        environmentVariablesOverride: [] // Initialize ENV overrides as empty array
      }

      const dockerBuild = {
        path: '.',
        options: [],
      }

      if (event.service.docker) {
        Object.assign(dockerBuild, event.service.docker)
      }

      // Default ENV variables as object (from prepared build)
      const defaultEnv = {
        BUILD_ID: event.builds.prepared.id,
        BUILD_NUMBER: event.builds.prepared.build_number.toString(),
        BUILD_BRANCH: event.builds.prepared.source_branch,
        BUILD_AUTHOR: event.builds.prepared.build_author,
        BUILD_SENDER: event.builds.prepared.build_sender,
        BUILD_PUSHER: event.builds.prepared.build_pusher,
        BUILD_PATH: dockerBuild.path,
        BUILD_OPTIONS: dockerBuild.options.join(' '),
        GIT_COMMIT_SHA: event.builds.prepared.source_version,
        GIT_BRANCH: event.builds.prepared.source_branch,
        // Set DEPLOY_BUILD=1 on ENV for every build if service has "tag_all" as true(thy)
        // TODO: How best to prune/clean ECR images over time?
        DEPLOY_BUILD: event.service.tag_all ? '1' : '0'
      }

      // If prepared build is release, add BUILD_RELEASE ENV variable
      if (event.builds.prepared.release) {
        defaultEnv['BUILD_RELEASE'] = event.builds.prepared.release
      }

      // Create new object copy from default ENV
      const env = Object.assign({}, defaultEnv)

      // Load ENV variables (if present) from service yaml definition
      if (event.service.env) {
        // Pull global ENV vars first
        const globalEnv = event.service.env.global || {}

        // Init empty object for build specific ENV vars (branch or release specific)
        let buildEnv = {}

        // If prepared build is release, and release env defined, set buildEnv to defined release ENV vars
        // [HIGH] TODO: Still use "release" keyword? or using regex detect semver tags? Allow regex keys in env def also?
        if (event.builds.prepared.release && event.service.env.release) {
          buildEnv = event.service.env.release
        // If service yaml definition has ENV vars for current branch, set buildEnv
        // [HIGH] TODO: Use regex match keys instead of static?
        } else if (event.service.env[event.builds.prepared.source_branch]) {
          buildEnv = event.service.env[event.builds.prepared.source_branch]
        }

        // Update env object with global & build ENV vars (as defined in service yaml)
        Object.assign(env, globalEnv, buildEnv)
      }

      // If service has defined deployments, add deployment ENV variables
      if (event.service.deployments) {
        // Init empty object for deployEnv
        let deployEnv = {}

        const shouldDeploy = (ref) => {
          const deployKeys = Object.keys(event.service.deployments)
          const deployPats = deployKeys.map(src => new RegExp(src))
          const deployList = deployPats.filter(pat => pat.test(ref))

          return deployList.length > 0
        }

        if (shouldDeploy(event.builds.prepared.source_branch)) {
          deployEnv['DEPLOY_BUILD'] = '1'
        }

        // Update env object with deployEnv object
        Object.assign(env, deployEnv)
      }

      // Loop through all keys in "env" object to construct ENV overrides for CodeBuild params
      Object.keys(env).forEach(name => {
        // Load var value by name
        const value = env[name]

        // Add new object to ENV overrides array (CodeBuild requires: { name: name, value: value })
        buildParams.environmentVariablesOverride.push({
          name,
          value
        })
      })

      // Debug log with build params
      console.log('Building with params:', buildParams)

      // Start CodeBuild from constructed params
      CodeBuild.startBuild(buildParams, (err, data) => {
        // Reject on error
        if (err) {
          console.log('Build Error:', err)
          reject(err)
        } else {
          // Debug logs for build response
          console.log('Build Data:', data)

          // Save started build from response data
          event.builds.started = data.build

          resolve(event)
        }
      })
    }
  )
}

/**
 * Track build details (update row in DynamoDB with build data)
 * @param {object} event
 */
module.exports.trackBuild = (event) => {
  return new Promise(
    (resolve, reject) => {
      // Ensure a build has been prepared and started
      if (!event.builds.prepared || !event.builds.started) {
        console.log('No build has been prepared/started, not tracking anything...')

        // Resolve gracefully if no build to be tracked
        resolve(event)
        return
      }

      // [HIGH] TODO: Failing
      // { ModelError: Key required to get item
      //   at Function.Model.get (/var/task/node_modules/dynamoose/dist/Model.js:516:25)
      //   at process._tickCallback (internal/process/next_tick.js:68:7) name: 'ModelError', message: 'Key required to get item' }
      Build.get(event.builds.prepared.id).then(data => {
        console.log('Found build data:', data)

        Object.assign(data, {
          build_id: event.builds.started.id,  // Track CodeBuild build ID
          build_arn: event.builds.started.arn // Track CodeBuild build ARN
        })

        if (process.env.SLS_BASE_URL && data.id) {
          let logsUrl = process.env.SLS_BASE_URL

          if (process.env.SLS_API_BASE) {
            logsUrl += `/${process.env.SLS_API_BASE}`
          }

          logsUrl += `/builds/${data.id}/logs`

          Object.assign(data, {
            build_logs: logsUrl
          })
        }

        return data.save().then(data => {
          console.log('Saved build data:', data)
          resolve(event)
        })
      }).catch(err => {
        console.log('Error getting build:', event.builds.prepared, err)
        resolve(event)
      })
    }
  )
}