simple-cfn
Version:
Simple AWS CloudFormation
483 lines (415 loc) • 13.9 kB
JavaScript
/**
* Cloud Formation Module. Handles stack creation, deletion, and updating. Adds
* periodic polling to check stack status. So that stack operations are
* synchronous.
*/
const _ = require('lodash')
const chalk = require('chalk')
const YAML = require('yamljs')
const AWS = require('aws-sdk')
const moment = require('moment')
const filesystem = require('fs')
const Promise = require('bluebird')
const get = require('lodash/fp/get')
const merge = require('lodash/merge')
const flow = require('lodash/fp/flow')
const keyBy = require('lodash/fp/keyBy')
const sprintf = require('sprintf-js').sprintf
const mapValues = require('lodash/fp/mapValues')
const HttpsProxyAgent = require('https-proxy-agent')
const fs = Promise.promisifyAll(filesystem)
AWS.config.setPromisesDependency(Promise)
const PROXY = process.env.PROXY || process.env.https_proxy || process.env.http_proxy
const success = [
'CREATE_COMPLETE',
'DELETE_COMPLETE',
'UPDATE_COMPLETE'
]
const failed = [
'ROLLBACK_FAILED',
'ROLLBACK_IN_PROGRESS',
'ROLLBACK_COMPLETE',
'UPDATE_ROLLBACK_IN_PROGRESS',
'UPDATE_ROLLBACK_COMPLETE',
'UPDATE_FAILED',
'DELETE_FAILED'
]
const exists = [
'CREATE_COMPLETE',
'UPDATE_COMPLETE',
'ROLLBACK_COMPLETE',
'UPDATE_ROLLBACK_COMPLETE'
]
const colorMap = {
CREATE_IN_PROGRESS: 'gray',
CREATE_COMPLETE: 'green',
CREATE_FAILED: 'red',
DELETE_IN_PROGRESS: 'gray',
DELETE_COMPLETE: 'green',
DELETE_FAILED: 'red',
DELETE_SKIPPED: 'gray',
ROLLBACK_FAILED: 'red',
ROLLBACK_IN_PROGRESS: 'yellow',
ROLLBACK_COMPLETE: 'red',
UPDATE_IN_PROGRESS: 'gray',
UPDATE_COMPLETE: 'green',
UPDATE_COMPLETE_CLEANUP_IN_PROGRESS: 'green',
UPDATE_ROLLBACK_IN_PROGRESS: 'yellow',
UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS: 'yellow',
UPDATE_ROLLBACK_FAILED: 'red',
UPDATE_ROLLBACK_COMPLETE: 'red',
UPDATE_FAILED: 'red'
}
const ings = {
create: 'Creating',
delete: 'Deleting',
update: 'Updating'
}
let _config = {
checkStackInterval: 5000
}
function SimpleCfn(name, template) {
const log = console.log
const opts = _.isPlainObject(name) ? name : {}
const awsOpts = {}
let startedAt = Date.now()
const params = opts.params
const cfParams = opts.cfParams || {}
const awsConfig = opts.awsConfig
const capabilities = opts.capabilities || ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM']
const tags = opts.tags || {}
const async = opts.async
const checkStackInterval = opts.checkStackInterval || _config.checkStackInterval
const stackRoleName = opts.stackRoleName || ""
if (PROXY) {
awsOpts.httpOptions = {
agent: new HttpsProxyAgent(PROXY)
}
}
if (awsConfig) {
_.merge(awsOpts, awsConfig)
}
// initialize cf
const cf = new AWS.CloudFormation(awsOpts)
name = opts.name || name
template = opts.template || template
function checkStack(action, name) {
const logPrefix = name + ' ' + action.toUpperCase()
const notExists = /ValidationError:\s+Stack\s+\[?.+]?\s+does not exist/
const throttling = /Throttling:\s+Rate\s+exceeded/
const displayedEvents = {}
return new Promise(function (resolve, reject) {
let interval = 0
let running = false
// on success:
// 1. clear interval
// 2. return resolved promise
function _success() {
clearInterval(interval)
return resolve()
}
// on fail:
// 1. build fail message
// 2. clear interval
// 3. return rejected promise with failed message
function _failure(msg) {
const fullMsg = logPrefix + ' Failed' + (msg ? ': ' + msg : '')
clearInterval(interval)
return reject(new Error(fullMsg))
}
function _processEvents(events) {
events = _.sortBy(events, 'Timestamp')
_.forEach(events, function (event) {
displayedEvents[event.EventId] = true
if (moment(event.Timestamp).valueOf() >= startedAt) {
const color = colorMap[event.ResourceStatus] || 'gray'
log(sprintf('[%s] %s %s: %s - %s %s %s',
chalk.gray(moment(event.Timestamp).format('HH:mm:ss')),
ings[action],
chalk.cyan(name),
event.ResourceType,
event.LogicalResourceId,
chalk[color](event.ResourceStatus),
chalk[colorMap[event.ResourceStatus]](event.ResourceStatus),
event.ResourceStatusReason || ''
))
}
})
const lastEvent = _.last(events) || {}
const timestamp = moment(lastEvent.Timestamp).valueOf()
const resourceType = lastEvent.ResourceType
const status = lastEvent.ResourceStatus
const statusReason = lastEvent.ResourceStatusReason
// Only fail/succeed on cloud formation stack resource
// and ensure we are not finishing on a nested template
if (resourceType === 'AWS::CloudFormation::Stack' && lastEvent.LogicalResourceId === name) {
// if cf stack status indicates failure AND the failed event occurred during this update, notify of failure
// if cf stack status indicates success, OR it failed before this current update, notify of success
if (_.includes(failed, status) && (timestamp >= startedAt)) {
_failure(statusReason)
} else if (_.includes(success, status) || (_.includes(failed, status) && (timestamp < startedAt))) {
_success()
}
}
running = false
}
// provides all pagination
function getAllStackEvents(stackName) {
let next
let allEvents = []
function getStackEvents() {
return cf.describeStackEvents({
StackName: stackName,
NextToken: next
})
.promise()
.then(function (data) {
next = (data || {}).NextToken
allEvents = allEvents.concat(data.StackEvents)
return next && !allRelevantEventsLoaded(data.StackEvents) ? getStackEvents() : Promise.resolve()
})
}
return getStackEvents().then(function () {
return allEvents
})
}
// events are loaded sorted by timestamp desc (newest events first)
// if we try to load all events we can easily run into throttling by aws api
function allRelevantEventsLoaded(events) {
return events.some(event => moment(event.Timestamp).valueOf() < startedAt)
}
function outputNewStackEvents() {
const events = []
if (running) {
return
}
running = true
return getAllStackEvents(name)
.then(function (allEvents) {
running = false
_.forEach(allEvents, function (event) {
// if event has already been seen, don't add to events to process list
if (displayedEvents[event.EventId]) {
return
}
events.push(event)
})
return _processEvents(events)
}).catch(function (err) {
// if stack does not exist, notify success
if (err && notExists.test(err)) {
return _success()
}
// if throttling has occurred, process events again
if (err && throttling.test(err)) {
log('AWS api calls are throttling')
return _processEvents(events)
}
// otherwise, notify of failure
if (err) {
return _failure(err)
}
})
}
// call once and repeat in intervals
outputNewStackEvents()
interval = setInterval(outputNewStackEvents, checkStackInterval)
})
}
function processCfStack(action, cfparms) {
startedAt = Date.now()
if (action === 'update') {
return cf.updateStack(cfparms).promise()
.catch(function (err) {
if (!/No updates are to be performed/.test(err)) {
throw err
} else {
log('No updates are to be performed.')
}
})
}
return cf.createStack(cfparms).promise()
}
function loadJs(path) {
const tmpl = require(path)
const fn = _.isFunction(tmpl) ? tmpl : function () {
return tmpl
}
return Promise.resolve(JSON.stringify(fn(params)))
}
function normalizeParams(templateObject, params) {
try {
params = YAML.parse(params)
} catch (error) {
//console.log(error)
}
if (!params) return Promise.resolve([])
if (!_.isPlainObject(params)) return Promise.resolve([])
if (_.keys(params).length <= 0) return Promise.resolve([])
// mutate params
_.keys(params).forEach(k => {
params[_.toLower(k)] = _.toString(params[k])
})
return cf.getTemplateSummary(templateObject).promise()
.then(data => {
const templateParams = data.Parameters || []
return templateParams.map(p => {
const k = _.toLower(p.ParameterKey)
const v = params.hasOwnProperty(k) ? String(params[k]) : undefined
return {
ParameterKey: p.ParameterKey,
ParameterValue: v !== undefined ? v : p.DefaultValue
}
})
})
}
function convertTags() {
if (!_.isPlainObject(tags)) return []
return (Object.keys(tags)).map(function (key) {
return {
Key: key,
Value: tags[key]
}
})
}
function isStringOfType(type, str) {
let result = true
try {
type.parse(str)
} catch (ignore) {
// console.log(ignore)
result = false
}
return result
}
function isJSONString(str) {
return isStringOfType(JSON, str)
}
function isYAMLString(str) {
return isStringOfType(YAML, str) && str.split(/\r\n|\r|\n/).length > 1
}
function isValidTemplateString(str) {
return isJSONString(str) || isYAMLString(str)
}
function templateBodyObject(template) {
return {
TemplateBody: template
}
}
function processTemplate(template) {
// Check if template is located in S3
if (isUriTemplate(template)) return Promise.resolve({ TemplateURL: template })
// Check if template if a `js` file
if (_.endsWith(template, '.js')) return loadJs(template).then(templateBodyObject)
// Check if template is an object, assume this is JSON good to go
if (_.isPlainObject(template)) return Promise.resolve(template).then(flow(JSON.stringify, templateBodyObject))
// Check if template is a valid string, serialised json or yaml
if (isValidTemplateString(template)) return Promise.resolve(templateBodyObject(template))
// Default to loading template from file.
return fs.readFileAsync(template, 'utf8').then(templateBodyObject)
}
function isUriTemplate(template) {
const httpsUri = /https:\/\/s3.+amazonaws.com/
return httpsUri.test(template)
}
function getStackRoleArnFromName(stackRoleName) {
var sts = new AWS.STS();
return sts.getCallerIdentity().promise().then(function (data) {
if (stackRoleName) {
return `arn:aws:iam::${data.Account}:role/${stackRoleName}`
} else {
return ""
}
}).catch(function (err) {
console.log(`this one ${err}`);
return ""
});
}
function processStack(action, name, template) {
return processTemplate(template)
.then(templateObject => {
return normalizeParams(templateObject, cfParams)
.then(noramlizedParams => {
return getStackRoleArnFromName(stackRoleName)
.then(stackRoleArn => {
var stackParams = {
StackName: name,
Capabilities: capabilities,
Parameters: noramlizedParams,
Tags: convertTags()
}
if (stackRoleArn) {
stackParams = merge({ RoleARN: stackRoleArn }, stackParams)
}
return processCfStack(action, merge(stackParams, templateObject))
})
})
})
.then(function () {
return async ? Promise.resolve() : checkStack(action, name)
})
}
this.stackExists = function (overrideName) {
return cf.describeStacks({ StackName: overrideName || name }).promise()
.then(function (data) {
return _.includes(exists, data.Stacks[0].StackStatus)
})
.catch(function () {
return false
})
}
this.createOrUpdate = function () {
return this.stackExists()
.then(function (exists) {
return processStack(exists ? 'update' : 'create', name, template)
})
}
this.validate = function () {
return processTemplate(template)
.then(function (templateObject) {
return cf.validateTemplate(templateObject).promise()
})
}
this.outputs = function () {
return cf.describeStacks({ StackName: name }).promise()
.then(function (data) {
return flow(
get('Stacks[0].Outputs'),
keyBy('OutputKey'),
mapValues('OutputValue')
)(data)
})
}
this.output = function (field) {
return this.outputs()
.then(function (data) {
return data[field] || ''
})
}
}
var simpleCfn = function (name, template) {
return new SimpleCfn(name, template).createOrUpdate()
}
simpleCfn.stackExists = function (name) {
return new SimpleCfn(name).stackExists()
}
simpleCfn.create = function (name, template) {
return new SimpleCfn(name, template).create()
}
simpleCfn.validate = function (template, params) {
return new SimpleCfn({
template: template,
params: params
}).validate()
}
simpleCfn.outputs = function (name) {
return new SimpleCfn(name).outputs()
}
simpleCfn.output = function (name, field) {
return new SimpleCfn(name).output(field)
}
simpleCfn.configure = function (cfg) {
_config = cfg
}
simpleCfn.class = SimpleCfn
module.exports = simpleCfn