@bowtie/sls
Version:
Serverless helpers & utilities
395 lines (331 loc) • 11.2 kB
JavaScript
// 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