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

const aliasBuildS3 = (version, tags) => {
  return new Promise(
    (resolve, reject) => {
      if (false) {
        resolve()
      } else {
        reject(new Error(`S3 build path not found for version: ${version}`))
      }
    }
  )
}

const aliasBuildEcr = (version, tags) => {
  return new Promise(
    (resolve, reject) => {
      // 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: 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)
          return reject(err)
        // Source version tag already exists!
        } else if (data.images.length === 1) {
          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) {
              if (err.code === 'ImageAlreadyExistsException') {
                console.log('Tag(s) already exists', tags);
                return resolve()
              } else {
                console.log('Failed to put new tag for ECR image', err)
                return reject(err)
              }
            }

            return resolve()
          })

        // Source version tag does not exist, insert build details into DynamoDB
        } else {
          reject(new Error(`ECR build image not found for version: ${version}`))
        }
      })
    }
  )
}

/**
 * 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
      }

      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 = ''
      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',
        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 = {
        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)

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

      const params = { source_version: event.builds.prepared.source_version }

      console.log('Scan builds with params', params)

      Build.scanAll(params).then(builds => {
        if (builds.length === 1) {
          const build = builds[0]

          // Update parsed build status, assume succeeded since tag already exists
          Object.assign(event.parsed.build, {
            status: build.build_status,
            number: build.build_number,
            found: true,
            model: build
          })

          if (build.build_status === 'SUCCEEDED') {
            if (!Array.isArray(build.build_tags)) {
              build.build_tags = []
            }

            build.build_tags = build.build_tags.concat(tags)

            const aliasBuild = event.service.target === 's3' ? aliasBuildS3 : aliasBuildEcr

            return aliasBuild(params.source_version, build.build_tags).then(() => build.saveNotify())
          } else {
            return Promise.reject(new Error(`Not updating build tags for build with status: '${build.build_status}'`))
          }
        } else {
          return Promise.reject(new Error(`Expected 1 build, but found ${builds.length}`))
        }
      }).then(resp => {
        console.log('Updated build tags', resp)

        // Already built & tagged, delete prepared build data
        // TODO: Can this be controlled via service spec/config? i.e. "Disable redundant / enable duplicate / etc"
        delete event.builds.prepared

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

        const newBuild = new Build(event.builds.prepared)

        newBuild.saveNotify().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 serviceBuild = {
        path: '.',
        options: [],
      }

      if (event.service.build) {
        Object.assign(serviceBuild, event.service.build)
      } else if (event.service.docker) {
        Object.assign(serviceBuild, event.service.docker)
      }

      // Default ENV variables as object (from prepared build)
      const defaultEnv = {
        BUILD_ID: event.builds.prepared.id,
        /**
         * TODO: Add additional env params for supporting s3 builds (bowtie/docker-builder:v3)
         */
        // S3 push method
        BUILD_PUSH: serviceBuild.push || 'sync',
        // Object path on s3 build bucket
        DEPLOY_TAG: event.builds.prepared.source_version,
        // // Object path on s3 site bucket
        // DEPLOY_ENV: 'live',
        // // CF Distro to invalidate after deploy
        // DEPLOY_DIST: 'ABC123',

        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: serviceBuild.path,
        BUILD_OPTIONS: serviceBuild.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))

          console.log('deployList matched patterns:', deployList)

          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

          Object.assign(event.parsed.build, {
            number: data.build.buildNumber
          })

          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(build => {
        console.log('Found build:', build)

        Object.assign(build, {
          build_id: event.builds.started.id, // Track CodeBuild build ID
          build_arn: event.builds.started.arn, // Track CodeBuild build ARN
          build_number: event.builds.started.buildNumber // Update build number from started CodeBuild instance
        })

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

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

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

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

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