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