@bowtie/sls
Version:
Serverless helpers & utilities
471 lines (394 loc) • 16 kB
JavaScript
// Include the query-string package
// TODO: Use "qs" package instead?
const queryString = require('query-string')
const { Build, Deploy } = require('./models')
/**
* Decode event.body using query-string.parse()
* @param {object} event
*/
module.exports.decodeBody = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure event has a body
if (!event.body) {
reject(new Error('Event has no body to be parsed.'))
return
}
try {
// Set the parsed body
event.parsed.body = queryString.parse(event.body)
// Resolve this promise
resolve(event)
} catch (e) {
reject(e)
}
}
)
}
/**
* Parse event.body using JSON.parse()
* @param {object} event
*/
module.exports.parseBody = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure event has a body
if (!event.body) {
reject(new Error('Event has no body to be parsed.'))
return
}
try {
// Set the parsed body
event.parsed.body = JSON.parse(event.body)
console.log('Parsed request body:', JSON.stringify(event.parsed.body, null, 2))
// Resolve this promise
resolve(event)
} catch (e) {
reject(e)
}
}
)
}
/**
* Parse decoded body payload
* @param {object} event
*/
module.exports.parsePayload = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the body has already been parsed and contains a command
if (!event.parsed.body || !event.parsed.body.payload) {
reject(new Error('Event has no parsed body and/or payload.'))
return
}
try {
event.parsed.payload = JSON.parse(event.parsed.body.payload)
resolve(event)
} catch (e) {
reject(e)
}
}
)
}
/**
* Parse SNS Records into Slack messages
* @param {object} event
*/
module.exports.stackChange = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the event has Records to be parsed into messages
if (!event.Records || event.Records.length === 0) {
reject(new Error('Event has no records to be parsed.'))
return
}
// Create empty array of parsed messages
event.parsed.messages = []
// Loop through all event Records and build messages
event.Records.forEach(r => {
// Create empty message object
const msg = {}
// Split on new line and process each line
r.Sns.Message.split('\n').forEach(line => {
// Split each line into key/value pairs
const parts = line.split('=')
// Assign key/value pairs to message object after sanitizing
if (parts.length === 2) {
const name = parts[0]
const value = parts[1].replace(/^\\|'/g, '')
try {
// Attempt to assign the value as a parsed object
msg[name] = JSON.parse(value)
} catch (e) {
// Gracefully catch failed JSON.parse() and assign raw value
msg[name] = value
}
} else {
// Unknown line format, unable to build message with parts
if (parts[0] && parts[0].replace(/^\\|'/g, '').trim() !== '') {
console.log('Unknown message parts:', parts)
}
}
})
// Add message to the parsed messages array (only if it has been created)
if (Object.keys(msg).length > 0) {
event.parsed.messages.push(msg)
}
})
// Resolve this promise
resolve(event)
}
)
}
/**
* Parse an event from a change in a CodeBuild service build
* @param {object} event
*/
module.exports.buildChange = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the event has detail with "build-status"
if (!event.detail || !event.detail['build-status']) {
reject(new Error('No build details and/or status found in event.'))
return
}
const { region } = event
let slug = event.service.source.repo
if (event.service.source.type === 'GITHUB') {
slug = event.detail['additional-information'].source.location.match(/^https?:\/\/github.com\/(.*)\.git$/)[1]
} else if (event.service.source.type === 'BITBUCKET') {
slug = event.detail['additional-information'].source.location.match(/^https?:\/\/bitbucket.org\/(.*)\.git$/)[1]
}
console.log('build event details:')
console.log(JSON.stringify(event.detail, null, 2))
// Build object for parsing build details
const parsedBuild = {
arn: event.detail['build-id'],
env: event.detail['additional-information'].environment['environment-variables'],
// arn:aws:codebuild:us-east-2:442555157363:build/sls-ci-example-app-us-east-2-build-project:d352736e-ec45-4370-9aed-1fb7d066a680
path: event.detail['build-id'].replace(/^arn:aws:codebuild:[^:]+:[0-9]+:/g, ''),
info: event.detail['additional-information'],
status: event.detail['build-status'],
complete: event.detail['additional-information']['build-complete'],
project_name: event.detail['project-name'],
region,
source: {
slug,
location: event.detail['additional-information'].source.location,
version: event.detail['additional-information']['source-version']
},
details: event.detail
}
console.log('parsed build data')
console.log(JSON.stringify(parsedBuild, null, 2))
// Find ENV vars by name
const findBuildId = parsedBuild.env.filter(v => v.name === 'BUILD_ID')
const findBuildBranch = parsedBuild.env.filter(v => v.name === 'BUILD_BRANCH')
const findBuildAuthor = parsedBuild.env.filter(v => v.name === 'BUILD_AUTHOR')
const findBuildSender = parsedBuild.env.filter(v => v.name === 'BUILD_SENDER')
const findBuildPusher = parsedBuild.env.filter(v => v.name === 'BUILD_PUSHER')
const findBuildRelease = parsedBuild.env.filter(v => v.name === 'BUILD_RELEASE')
// Set additional parsedBuild fields, default to 'unknown'
parsedBuild.id = (findBuildId.length > 0) ? findBuildId[0].value : 'unknown'
parsedBuild.author = (findBuildAuthor.length > 0) ? findBuildAuthor[0].value : 'unknown'
parsedBuild.sender = (findBuildSender.length > 0) ? findBuildSender[0].value : 'unknown'
parsedBuild.pusher = (findBuildPusher.length > 0) ? findBuildPusher[0].value : 'unknown'
parsedBuild.release = (findBuildRelease.length > 0) ? findBuildRelease[0].value : false
parsedBuild.source.branch = (findBuildBranch.length > 0) ? findBuildBranch[0].value : 'unknown'
// If build is complete and contains phases info, calculate total running time
if (parsedBuild.complete && parsedBuild.info.phases) {
// Reduce (sum) "duration-in-seconds" value from each build phase
parsedBuild.duration = parsedBuild.info.phases.map(p => (p['duration-in-seconds'] || 0)).reduce((a, b) => a + b)
}
// Load link to build logs (if present)
// [HIGH] TODO: Better way for devs to see logs? Can they be live? Can this link be created from build info (arn etc)?
// if (parsedBuild.info.logs && parsedBuild.info.logs['deep-link']) {
// parsedBuild.link = parsedBuild.info.logs['deep-link']
// }
if (parsedBuild.arn) {
parsedBuild.link = `https://${region}.console.aws.amazon.com/codesuite/codebuild/projects/${parsedBuild.project_name}/${parsedBuild.path}/log?region=${region}`
}
event.parsed.build = parsedBuild
Build.get(parsedBuild.id).then(build => {
if (build) {
Object.assign(build, {
build_status: parsedBuild.status,
build_link: parsedBuild.link
})
build.saveNotify().then(resp => {
console.log('saved build', resp, build)
event.parsed.build.model = build
event.parsed.build.number = build.build_number
resolve(event)
}).catch(reject)
} else {
console.warn('Unable to find build', parsedBuild)
resolve(event)
}
}).catch(reject)
// Build.scanAll({ build_arn: { eq: parsedBuild.arn } }).then(builds => {
// if (builds.length > 0) {
// const build = builds[0]
// Object.assign(build, {
// build_status: parsedBuild.status,
// build_link: parsedBuild.link
// })
// build.saveNotify().then(resp => {
// console.log('saved build', resp, build)
// resolve(event)
// }).catch(reject)
// } else {
// if (builds.length === 0) {
// console.warn('Unable to find build', parsedBuild)
// } else if (builds.length > 1) {
// console.warn('Found multiple builds with arn:', parsedBuild.arn)
// }
// resolve(event)
// }
// }).catch(reject)
// Set parsed build info
// event.parsed.build = parsedBuild
// resolve(event)
}
)
}
/**
* Parse deployments from event build details
* @param {object} event
*/
module.exports.deployments = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the build change details have been parsed
if (!event.parsed.build) {
console.log('No parsed build to parse deployments from')
resolve(event)
return
}
// Init parsed deployments to empty array
event.parsed.deployments = []
console.log('parsed', event.parsed)
// If parsed build has succeeded and service yaml contains deployment definitions, parse deployment details
if (event.parsed.build.status === 'SUCCEEDED' && event.service.deployments) {
// Tag to be deployed (from ECR repository) (prioritize release, then build number)
let tag = event.parsed.build.release || event.parsed.build.number
// Default deployKey to the source branch (key for deployments definition in service yaml)
let deployKey = event.parsed.build.source.branch
// Debug logs
console.log('parsed build', event.parsed.build)
// console.log('service release deployments', event.service.deployments['release'])
console.log(`using deployKey: '${deployKey}'`)
Object.keys(event.service.deployments).forEach(deploySource => {
// Init deployment as null (not sure if deploying this branch/tag yet...)
let deployment = null
console.log(`checking deploySource: '${deploySource}'`)
const srcRegEx = new RegExp(deploySource)
// Test using RegExp (check if branch/ref matches key in deployments config)
if (srcRegEx.test(deployKey)) {
console.log('deployKey', deployKey, 'matches deploySource exact or as RegEx', srcRegEx)
deployment = event.service.deployments[deploySource]
console.log('using deployment', deployment)
} else {
console.log('NO MATCH: deployKey', deployKey, 'does not match deploySource as RegEx', srcRegEx)
}
// If deployment defined (and not already queued in parsed array), parse into deployment details and add to array
if (deployment && !event.parsed.deployments.find(deploy => deploy.name === deployment.name)) {
// Ensure targets is used as an array of strings
const targetDeployments = Array.isArray(deployment) ? deployment : [ deployment ]
console.log('pulling targetDeployments from deployment', targetDeployments)
// If at least one target, parse deployment details
if (targetDeployments.length > 0) {
// Loop through targetDeployments
targetDeployments.forEach(deployConfig => {
const { name, overrides = {} } = deployConfig
const deployInfo = {
tag: tag.toString(),
env: event.helpers.envGetAlias(name),
user: event.parsed.build.sender,
repo: event.service.source.repo,
rev: event.parsed.build.source.version,
stack: name,
target: event.service.target,
release: event.helpers.tagIsRelease(tag),
build_id: event.parsed.build.id,
deployment
}
const deployData = Object.assign({}, deployConfig, deployInfo, overrides)
console.log(`Queueing deploy for '${name}' with data:`, deployData)
// Push deployment details for current
event.parsed.deployments.push(deployData)
})
}
}
})
} else {
// Debug logs
console.log('Not deploying build:', event.parsed.build)
}
resolve(event)
}
)
}
/**
* Parse info from a Slack slash command event
*/
module.exports.slackCommand = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the body has already been parsed and contains a command
if (!event.parsed.body || !event.parsed.body.command) {
reject(new Error('Event has no parsed body and/or command.'))
return
}
// Ensure the parsed body contains Slack team info
if (!event.parsed.body.team_id || !event.parsed.body.team_domain) {
reject(new Error('Command event is missing team information'))
return
}
// Ensure the parsed body contains Slack channel info
if (!event.parsed.body.channel_id || !event.parsed.body.channel_name) {
reject(new Error('Command event is missing channel information.'))
return
}
// Ensure the parsed body contains Slack user info
if (!event.parsed.body.user_id || !event.parsed.body.user_name) {
reject(new Error('Command event is missing user information.'))
return
}
// Create the parsed Slack command object
event.parsed.slackCommand = {
// Initialize args as empty array
args: [],
// Reference the entire parsed body as "request" for this command event
request: event.parsed.body,
// The actual command (string) being handled
command: event.parsed.body.command,
// Slack team object
team: {
id: event.parsed.body.team_id,
domain: event.parsed.body.team_domain
},
// Slack channel object
channel: {
id: event.parsed.body.channel_id,
name: event.parsed.body.channel_name
},
// Slack user object
user: {
id: event.parsed.body.user_id,
name: event.parsed.body.user_name
}
}
// If the command was sent with "text", parse the text into arguments (split on whitespace)
if (event.parsed.body.text && event.parsed.body.text.trim() !== '') {
event.parsed.slackCommand.args = event.parsed.body.text.replace(/\s\s+/g, ' ').split(' ')
}
// Resolve this promise
resolve(event)
}
)
}
/**
* Parse info from a Slack response
*/
module.exports.slackResponse = (event) => {
// Return a promise for the promise chain
return new Promise(
(resolve, reject) => {
// Ensure the payload has already been parsed
if (!event.parsed.payload) {
reject(new Error('Event has no parsed payload.'))
return
}
event.parsed.slackResponse = event.parsed.payload
resolve(event)
}
)
}