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