UNPKG

@bowtie/sls

Version:

Serverless helpers & utilities

395 lines (331 loc) 11.2 kB
// Include the AWS SDK const AWS = require('aws-sdk') const ECR = new AWS.ECR() const CodeBuild = new AWS.CodeBuild() const CloudFormation = new AWS.CloudFormation() const CloudWatchLogs = new AWS.CloudWatchLogs() const uuidv1 = require('uuid/v1') const { dynamoose } = require('../config') const { Schema } = dynamoose const { Deploy } = require('./Deploy') const pubnub = require('../pubnub') const { defaults, helpers } = require('../config') const { parseServiceConfig, scanRecursive } = require('../utils') const { envGetAlias, tagIsRelease } = helpers const BuildSchema = new Schema({ id: { type: String, required: true, hashKey: true, default: (model) => uuidv1() }, project_name: String, service_name: String, build_timestamp: Number, build_status: { type: String, required: true, enum: ['CREATED', 'IN_PROGRESS', 'SUCCEEDED', 'FAILED', 'STOPPED'], default: 'CREATED' }, build_arn: String, build_author: String, build_id: String, build_number: Number, build_pusher: String, build_sender: String, build_link: String, build_logs: String, build_tags: Array, build_size: Number, build_digest: String, source_branch: String, source_version: String, release: String }, { timestamps: true, throughput: 'ON_DEMAND' }) const BuildModel = dynamoose.model(process.env.BUILDS_TABLE_NAME, BuildSchema); BuildModel.methods.set('scanAll', async function (params = {}) { return scanRecursive(this, params) }) BuildModel.methods.set('findOne', async function (params = {}, context = {}) { const builds = await this.scanAll(params) let build = null if (builds.length === 0) { throw new Error(`FAIL: Build.findOne() failed lookup with: ${JSON.stringify(params)}`) } else if (builds.length > 0) { // throw new Error(`FAIL: Build.findOne() found multiple with: ${JSON.stringify(params)}`) // Just log warning and find "best match" build instead of failing here ... s3 builds duplicate atm console.warn(`WARN: Build.findOne() found multiple with: ${JSON.stringify(params)}`) // Find release build = builds.find(b => b.source_branch === b.release) if (!build) { let lastUpdate = 0 // Scan result builds, find most recent "updatedAt" timestamp to return builds.forEach(b => { if (b.updatedAt && b.updatedAt > lastUpdate) { console.log(`INFO: Found recent build: ${b.build_number} [${b.updatedAt} > ${lastUpdate}] - Still looking ...`) lastUpdate = b.updatedAt build = b } }) if (!build && builds[0]) { build = builds[0] } } } return build }) BuildModel.methods.document.set('saveNotify', function (options = {}) { /** * TODO: Refactor (like Deploy) - Automate tag sync, ECR alias/verify + s3 sync / cf distro */ return new Promise( (resolve, reject) => { Object.assign(this, { service_name: process.env.SERVICE_NAME, /* required */ project_name: process.env.BUILD_PROJECT_NAME, /* required */ }) this.save().then(data => { console.log('Build saveNotify data', data) if (pubnub) { pubnub.publish({ channel: 'builds', message: { action: 'saved', service: process.env.SERVICE_NAME, subject: this } }, (status, response) => { console.log('PN Publish', { status, response }) resolve(data) }) } else { console.log('PubNub is not configured, not publishing updates') resolve(data) } }).catch(reject) } ) }) /** * TODO: Implement proper deploy for other target(s) "s3" vs "ecs", etc */ BuildModel.methods.document.set('deploy', function (options = {}) { return new Promise( (resolve, reject) => { const { tag, stack } = options if (!tag || !(this.build_tags && this.build_tags).includes(tag)) { tag = this.build_number } // Describe the stack (verifies it exists, and can build parameter list) CloudFormation.describeStacks({ // Get the stack name from the parsed service parameters StackName: stack }, (err, data) => { // Reject on error if (err) { console.warn(err) console.log('Unable to find CF Stack with name:', stack) reject(err) return } // This should be impossible, but ensure only a single stack is being returned if (data.Stacks.length !== 1) { reject(new Error(`More than 1 stack matching name: ${stack}`)) return } const { Parameters } = data.Stacks[0] // const currentTagParam = Parameters.find(p => p.ParameterKey === 'Tag') // if (currentTagParam && currentTagParam.ParameterValue === tag) { // console.log('Stack is already using tag', tag, 'using source version for tag to trigger change', build.source_version) // tag = build.source_version // } // Filter current stack parameters except for "Tag" const params = Parameters.map(p => { return { ParameterKey: p.ParameterKey, UsePreviousValue: true } }).filter(p => p.ParameterKey !== 'Tag') // Add the "Tag" parameter with the tag as specified by the parsed service parameters params.push({ ParameterKey: 'Tag', ParameterValue: tag.toString() }) // Update the CloudFormation stack with the reconstructed parameters CloudFormation.updateStack({ // Get the stack name from the parsed service parameters StackName: stack, // Should include all previous parameters, and an updated "Tag" parameter Parameters: params, // Reuse the previous stack template UsePreviousTemplate: true, // Execute this stack update with the given role ARN // - This is passed into the environment by serverless (see "serverless.yml") RoleARN: process.env.UPDATE_ROLE_ARN, // A stack that creates/changes IAM roles/policies/etc MUST provide this capability flag Capabilities: ['CAPABILITY_IAM'], // Send stack change notifications to the SNS topic ARN // - This is passed into the environment by serverless (see "serverless.yml") // - This SNS topic is automatically hooked up to the "stack-change" function to send messages to Slack NotificationARNs: [process.env.NOTIFY_SNS_ARN] }, (err) => { if (err) { reject(err) return } const deployment = { tag, stack, env: envGetAlias(stack), rev: this.source_version, release: tagIsRelease(tag), service_name: process.env.SERVICE_NAME, deploy_status: 'IN_PROGRESS', deploy_timestamp: Date.now(), build_id: this.id } resolve(deployment) // TODO: Why doesn't this work? // const newDeploy = new Deploy({ // tag, // stack, // env: /(\-|_)prod(uction)?$/.test(stack) ? 'production' : 'staging', // rev: this.source_version, // service_name: process.env.SERVICE_NAME, // deploy_status: 'IN_PROGRESS', // deploy_timestamp: Date.now() // }) // newDeploy.saveNotify().then(data => { // console.log('Saved deploy data:', data) // resolve(newDeploy) // }).catch(reject) }) }) } ) }) BuildModel.methods.document.set('syncImage', async function (options = {}) { // Return early, this only applies for ECS targets if (parseServiceConfig()['target'] !== 'ecs') { return this; } const details = { build_tags: [], build_size: 0, build_digest: '' } try { const image = await this.image() Object.assign(details, { build_tags: image.imageTags, build_size: image.imageSizeInBytes, build_digest: image.imageDigest, }) } catch (err) { console.warn('WARN', err.message || err) } Object.assign(this, details) await this.saveNotify() return this }) BuildModel.methods.document.set('image', function (options = {}) { return new Promise( async (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: this.source_version // commit SHA (hash) } ] } // Search ECR for existing version tag (using commit SHA) ECR.batchGetImage(ecrParams, (err, data) => { // Log on error if (err) { reject(err) return // Source version tag already exists! } else if (data.images.length !== 1) { reject(new Error(`Expecting to find 1 image, found: ${data.images.length}`)) return } const image = data.images[0] const imageParams = { repositoryName: process.env.ECR_REPO_NAME, imageIds: [image.imageId] } ECR.describeImages(imageParams, (err, data) => { if (err) { reject(err) return } else if (data.imageDetails.length !== 1) { reject(new Error(`Expecting to find 1 image, found: ${data.imageDetails.length}`)) return } resolve(data.imageDetails[0]) }) }) } ) }) BuildModel.methods.document.set('logs', function (options = {}) { return new Promise( async (resolve, reject) => { const codeBuild = await this.codeBuild() const params = { logGroupName: codeBuild.logs.groupName, logStreamName: codeBuild.logs.streamName } CloudWatchLogs.getLogEvents(params, (err, data) => { if (err) { reject(err) return } const output = data.events.map(e => e.message).join('') resolve(output) }) } ) }) BuildModel.methods.document.set('codeBuild', function (options = {}) { return new Promise( (resolve, reject) => { if (!this.build_id) { reject(new Error(`Missing build_id`)) return } CodeBuild.batchGetBuilds({ ids: [this.build_id] }, (err, data) => { if (err) { reject(err) return } resolve(data.builds[0]) }) } ) }) BuildModel.methods.document.set('parse', function (options = {}) { return { id: this.id, arn: this.build_arn, number: this.build_number, author: this.build_author, sender: this.build_sender, pusher: this.build_pusher, release: this.release, timestamp: this.build_timestamp, source: { version: this.source_version, branch: this.source_branch } } }) module.exports = BuildModel