UNPKG

@bowtie/sls

Version:

Serverless helpers & utilities

468 lines (387 loc) 15.7 kB
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) }) } ) }