@silvermine/cloudformation-custom-resources
Version:
Collection of custom resources for CloudFormation that anyone can use.
538 lines (449 loc) • 24.1 kB
JavaScript
'use strict';
var _ = require('underscore'),
Q = require('q'),
AWS = require('aws-sdk'),
util = require('util'),
dynamo = new AWS.DynamoDB(),
BaseResource = require('./BaseResource'),
AWS_REGION = process.env.AWS_REGION;
module.exports = BaseResource.extend({
normalizeResourceProperties: function(props, allowErrors) {
if (props.DeleteUnneededTables && props.DeleteUnneededTables === 'true') {
props.DeleteUnneededTables = true;
} else {
props.DeleteUnneededTables = false;
}
if (props.DeploymentRegions) {
props.ReplicationGroup = _.map(props.DeploymentRegions, function(dr) {
return { RegionName: dr.region };
});
}
if (allowErrors && !props.LastStackUpdate) {
throw new Error('You must supply the LastStackUpdate property for global table resources. See docs.');
}
return props;
},
// In doCreate and doUpdate we delay ten seconds before starting any operations that
// will describe tables because while tables are being created or updated, our describe
// table operation may either (a) not return the table, or (b) return an old
// description of the table. Note that we are assuming (b) based on the documentation
// that clearly states (a) for DescribeTable after CreateTable [1]. It only seems
// logical that describing the table immediately after it was updated would yield the
// same problem because of the eventually consistent query. Thus, this is a safety
// measure to try to avoid getting tables out of sync between regions. While that might
// seem like it would only need to happen in doUpdate, because doCreate is creating the
// global table, we actually don't know in doCreate if the DynamoDB table was also just
// created, or if it has existed for some time and now our global table is being
// created; thus, the actual DynamoDB table could have just been updated. For example,
// perhaps it was created earlier, and just now an index or stream specification is
// being added to it, at the same time our global table was added to the stack.
//
// [1] https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#describeTable-property
//
// Says: Note: If you issue a DescribeTable request immediately after a CreateTable
// request, DynamoDB might return a ResourceNotFoundException. This is because
// DescribeTable uses an eventually consistent query, and the metadata for your table
// might not be available at that moment. Wait for a few seconds, and then try the
// DescribeTable request again.
doCreate: function(props) {
var tableName = props.GlobalTableName,
allRegions = _.pluck(props.ReplicationGroup, 'RegionName'),
copyTableRegions = _.chain(props.ReplicationGroup).pluck('RegionName').without(AWS_REGION).value();
console.log('Pausing ten seconds before starting create for global table %s in regions %s', tableName, allRegions);
return Q.delay(10000)
.then(this._printDescriptionsOfTables.bind(this, tableName, [ AWS_REGION ])) // for helpful debugging
.then(this._ensureTableCopiedToRegions.bind(this, tableName, copyTableRegions))
.then(this._printDescriptionsOfTables.bind(this, tableName, allRegions)) // for helpful debugging
.then(this._ensureGlobalTableConsistent.bind(this, props));
},
doUpdate: function(resourceID, props, oldProps) {
var tableName = props.GlobalTableName,
allRegions = _.pluck(props.ReplicationGroup, 'RegionName'),
oldRegions = _.pluck(oldProps.ReplicationGroup, 'RegionName'),
copyTableRegions = _.without(allRegions, AWS_REGION),
oldCopyTableRegions = _.without(oldRegions, AWS_REGION);
console.log('Pausing ten seconds before starting update for global table %s in regions %s', tableName, allRegions);
return Q.delay(10000)
.then(this._printDescriptionsOfTables.bind(this, tableName, [ AWS_REGION ])) // for helpful debugging
.then(this._ensureTableCopiedToRegions.bind(this, tableName, copyTableRegions))
.then(this._printDescriptionsOfTables.bind(this, tableName, _.uniq(allRegions.concat(oldRegions)))) // for helpful debugging
.then(this._ensureGlobalTableConsistent.bind(this, props))
.then(function(globalTableCloudFormationResp) {
var regionsToDelete = _.difference(oldCopyTableRegions, copyTableRegions);
if (props.DeleteUnneededTables) {
return this._removeTableFromRegions(tableName, regionsToDelete)
.then(_.constant(globalTableCloudFormationResp));
}
console.log('Not deleting table %s from regions %s because DeleteUnneededTables was not truthy', tableName, regionsToDelete);
return globalTableCloudFormationResp;
}.bind(this));
},
doDelete: function(resourceID, props) {
var tableName = props.GlobalTableName,
copyTableRegions = _.chain(props.ReplicationGroup).pluck('RegionName').without(AWS_REGION).value();
if (props.DeleteUnneededTables) {
return this._removeTableFromRegions(tableName, copyTableRegions)
.then(_.constant({ PhysicalResourceId: props.GlobalTableName }));
}
console.log('Not deleting replica %s tables in %s because DeleteUnneededTables was not truthy', tableName, copyTableRegions);
return Q.when({ PhysicalResourceId: props.GlobalTableName });
},
_ensureTableCopiedToRegions: function(tableName, regions) {
var self = this;
// Wait for the table to be in any state but DELETING:
return this._describeTableUntilState(tableName, AWS_REGION, [ 'CREATING', 'ACTIVE', 'UPDATING' ])
.then(function(masterDesc) {
if (!self._hasRequiredStreamSpec(masterDesc)) {
throw new Error('The master table ' + tableName + ' does not have the required NEW_AND_OLD_IMAGES stream enabled');
}
return self._listTags(AWS_REGION, masterDesc.TableArn)
.then(function(tags) {
return Q.all(_.map(regions, self._ensureTableCopiedToRegion.bind(self, tableName, masterDesc, tags)));
});
});
},
_ensureTableCopiedToRegion: function(tableName, masterDesc, masterTags, region) {
var self = this,
dyn = new AWS.DynamoDB({ region: region });
return this._describeTable(tableName, region)
.then(function(copyDesc) {
var params;
if (copyDesc) {
params = self._makeUpdateTableParams(tableName, region, masterDesc, copyDesc);
if (params) {
console.log('Updating a copy of DynamoDB table %s in %s: %j', tableName, region, params);
return Q.ninvoke(dyn, 'updateTable', params);
}
} else {
params = self._makeCreateTableParamsFromDescription(masterDesc);
console.log('Creating a copy of DynamoDB table %s in %s: %j', tableName, region, params);
return Q.ninvoke(dyn, 'createTable', params);
}
return { TableDescription: copyDesc };
})
.then(function(createOrUpdateResp) {
var arn = createOrUpdateResp.TableDescription.TableArn;
return self._listTags(region, arn)
.then(function(copyTags) {
if (_.isEqual(masterTags, copyTags)) {
console.log('No change needed for tags on %s in %s: %j', tableName, region, copyTags);
return;
}
console.log('Tagging table %s in %s with tags %j', tableName, region, masterTags);
return Q.ninvoke(dyn, 'tagResource', { ResourceArn: arn, Tags: masterTags });
});
});
},
_listTags: function(region, arn) {
var def = Q.defer(),
dyn = (region === AWS_REGION) ? dynamo : new AWS.DynamoDB({ region: region }),
attempts = 0,
timeout = 2000;
function doOnce() {
attempts = attempts + 1;
return Q.ninvoke(dyn, 'listTagsOfResource', { ResourceArn: arn })
.catch(function(err) {
if (err.code === 'ResourceNotFoundException') {
console.log('Could not list tags for %s because of ResourceNotFoundException', arn);
return false;
}
throw err;
})
.then(function(tagsResp) {
if (tagsResp) {
if (tagsResp.NextToken) {
def.reject(new Error('Too many tags on table ' + arn + ' for this simplistic tag replication'));
return;
}
def.resolve(tagsResp.Tags);
return;
}
// We allow 15 attempts here (as opposed to 10 when waiting on tables in
// certain states) because it seems to take longer for the
// list-tags-of-resource operation to start showing a new table.
if (attempts < 15) {
console.log('Will try listing tags for %s again in %s seconds', arn, (timeout / 1000));
Q.delay(timeout).then(doOnce).done();
timeout = Math.min(10000, timeout * 1.5);
} else {
def.reject(new Error(util.format('ERROR: Exhausted all %d attempts waiting for %s to have tags', attempts, arn)));
}
})
.catch(def.reject.bind(def));
}
Q.nextTick(doOnce);
return def.promise;
},
_removeTableFromRegions: function(tableName, regions) {
var self = this;
if (_.contains(regions, AWS_REGION)) {
throw new Error('Should not delete table %s from master region %s', tableName, AWS_REGION);
}
return Q.all(_.map(regions, function(region) {
var dyn = new AWS.DynamoDB({ region: region });
return self._describeTable(tableName, region)
.then(function(desc) {
if (desc) {
console.log('Deleting table %s in region %s', tableName, region);
return Q.ninvoke(dyn, 'deleteTable', { TableName: tableName })
.then(function() {
console.log('Done deleting table %s in region %s', tableName, region);
});
}
});
}));
},
_describeTable: function(tableName, region) {
var dyn = (region === AWS_REGION) ? dynamo : new AWS.DynamoDB({ region: region });
return Q.ninvoke(dyn, 'describeTable', { TableName: tableName })
.catch(function(err) {
if (err.code === 'ResourceNotFoundException') {
console.log('Table %s does not exist in %s', tableName, region);
return false;
}
throw err;
})
.then(function(resp) {
return resp ? resp.Table : resp;
});
},
_describeTableUntilState: function(tableName, region, desiredStates) {
var self = this,
def = Q.defer(),
attempts = 0,
timeout = 2000;
function doOnce() {
attempts = attempts + 1;
return self._describeTable(tableName, region)
.then(function(desc) {
if (desc && _.contains(desiredStates, desc.TableStatus)) {
// Have table, and it's in the desired state ... done!
return def.resolve(desc);
} else if (desc) {
// Have table, but not in valid state ... try again
console.log('Table %s in %s currently %s (waiting for %s)', tableName, region, desc.TableStatus, desiredStates);
} else {
// Don't have table yet ... try again
console.log('Table %s in %s does not yet exist (waiting for it in %s state)', tableName, region, desiredStates);
}
if (attempts < 10) {
console.log('Will try describing %s in %s again in %s seconds', tableName, region, (timeout / 1000));
Q.delay(timeout).then(doOnce).done();
timeout = Math.min(10000, timeout * 1.5);
} else {
// eslint-disable-next-line max-len
def.reject(new Error(util.format('ERROR: Exhausted all %d attempts waiting for %s:%s to be %s', attempts, tableName, region, desiredStates)));
}
})
.catch(def.reject.bind(def));
}
Q.nextTick(doOnce);
return def.promise;
},
_printDescriptionsOfTables: function(tableName, regions) {
return Q.all(_.map(regions, function(region) {
return this._describeTable(tableName, region)
.then(function(resp) {
console.log('Table description for %s:%s: %j', tableName, region, resp);
});
}.bind(this)));
},
_hasRequiredStreamSpec: function(desc) {
return desc.StreamSpecification &&
desc.StreamSpecification.StreamEnabled &&
desc.StreamSpecification.StreamViewType === 'NEW_AND_OLD_IMAGES';
},
_makeCreateTableParamsFromDescription: function(desc) {
var params = _.pick(desc, 'AttributeDefinitions', 'KeySchema', 'TableName', 'StreamSpecification'),
srcBillingMode = (desc.BillingModeSummary ? desc.BillingModeSummary.BillingMode : null);
if (srcBillingMode) {
params.BillingMode = srcBillingMode;
}
if (srcBillingMode !== 'PAY_PER_REQUEST') {
params.ProvisionedThroughput = _.pick(desc.ProvisionedThroughput, 'ReadCapacityUnits', 'WriteCapacityUnits');
}
if (!_.isEmpty(desc.LocalSecondaryIndexes)) {
params.LocalSecondaryIndexes = _.map(desc.LocalSecondaryIndexes, function(lsi) {
return _.pick(lsi, 'IndexName', 'KeySchema', 'Projection');
});
}
if (!_.isEmpty(desc.GlobalSecondaryIndexes)) {
params.GlobalSecondaryIndexes = _.map(desc.GlobalSecondaryIndexes, function(gsi) {
var newGSI = _.pick(gsi, 'IndexName', 'KeySchema', 'Projection');
if (srcBillingMode !== 'PAY_PER_REQUEST') {
newGSI.ProvisionedThroughput = _.pick(gsi.ProvisionedThroughput, 'ReadCapacityUnits', 'WriteCapacityUnits');
}
return newGSI;
});
}
return params;
},
_makeUpdateTableParams: function(tableName, destRegion, master, dest) {
var params = _.pick(master, 'AttributeDefinitions', 'TableName'),
destParams = _.pick(dest, 'AttributeDefinitions', 'TableName'),
srcBillingMode = (master.BillingModeSummary ? master.BillingModeSummary.BillingMode : null),
destBillingMode = (dest.BillingModeSummary ? dest.BillingModeSummary.BillingMode : null),
baseParamsAreEqual = _.isEqual(params, destParams) && (srcBillingMode === destBillingMode),
indexesBeingUpdated = [];
// NOTE: on updates we do not copy the provisioned throughput from the master table
// because we never manage throughput through CloudFormation ... we always intend to
// either manage it with our own DynamoDB Capacity Manager (via the
// core:dynamo-provisioning service), or through AWS' own auto-scaling. We would not
// want to compare the current provisioned capacity of the master and dest table and
// copy them here because we could cause errors.
// Similarly, we do not update the stream status because it should never change
// after the initial creation since global tables require a specific type of stream.
if (srcBillingMode && srcBillingMode !== destBillingMode) {
params.BillingMode = srcBillingMode;
}
// The provisioned throughput setting should only be copied when switching a table
// from on-demand to provisioned. In this case, the table needs an "initial"
// throughput set. However, in all other cases we don't want to copy this value (see
// the note above)
if (srcBillingMode !== 'PAY_PER_REQUEST' && destBillingMode === 'PAY_PER_REQUEST') {
params.ProvisionedThroughput = _.pick(master.ProvisionedThroughput, 'ReadCapacityUnits', 'WriteCapacityUnits');
}
params.GlobalSecondaryIndexUpdates = [];
// Find indexes on the master table that are deleting (and need to be deleted on the
// destination table), or are missing on the destination and thus need to be
// created.
_.each(master.GlobalSecondaryIndexes, function(masterGSI) {
var destGSI = _.findWhere(dest.GlobalSecondaryIndexes, { IndexName: masterGSI.IndexName }),
gsiUpdate;
if (destGSI && masterGSI.IndexStatus === 'DELETING') {
console.log(
'Need to delete index %s:%s in %s because it exists on dest table and is DELETING on the master table',
tableName,
masterGSI.IndexName,
destRegion
);
params.GlobalSecondaryIndexUpdates.push({ Delete: _.pick(masterGSI, 'IndexName') });
indexesBeingUpdated.push(masterGSI.IndexName);
} else if (!destGSI) {
console.log('Need to create index %s:%s in %s', tableName, masterGSI.IndexName, destRegion);
gsiUpdate = { Create: _.pick(masterGSI, 'IndexName', 'KeySchema', 'Projection') };
if (srcBillingMode !== 'PAY_PER_REQUEST') {
gsiUpdate.Create.ProvisionedThroughput = _.pick(masterGSI.ProvisionedThroughput, 'ReadCapacityUnits', 'WriteCapacityUnits');
}
params.GlobalSecondaryIndexUpdates.push(gsiUpdate);
indexesBeingUpdated.push(masterGSI.IndexName);
}
});
// If the source table's billing mode is 'PROVISIONED', but the destination table's
// mode is 'PAY_PER_REQUEST', then we will be changing it to PROVISIONED, and thus
// need to update all the indexes to include the provisioned capacity.
// Note that there's some oddness here: when the table's billing mode is
// 'PROVISIONED', you may not actually get back the BillingModeSummary in the table
// description. That's why we use `srcBillingMode !== 'PAY_PER_REQUEST'` everywhere
// in this class - because if it's pay per request, you'll always get the billing
// mode back.
if (srcBillingMode !== 'PAY_PER_REQUEST' && destBillingMode === 'PAY_PER_REQUEST') {
_.each(master.GlobalSecondaryIndexes, function(masterGSI) {
if (_.contains(indexesBeingUpdated, masterGSI.IndexName) || masterGSI.IndexStatus === 'DELETING') {
// This index is already in our call params, or it's being deleted.
return;
}
params.GlobalSecondaryIndexUpdates.push({
Update: {
IndexName: masterGSI.IndexName,
ProvisionedThroughput: _.pick(masterGSI.ProvisionedThroughput, 'ReadCapacityUnits', 'WriteCapacityUnits'),
},
});
});
}
// Now find indexes that only the destination table has, since they must have been
// deleted from the master table.
_.each(dest.GlobalSecondaryIndexes, function(destGSI) {
var masterGSI = _.findWhere(master.GlobalSecondaryIndexes, { IndexName: destGSI.IndexName });
if (!masterGSI) {
console.log(
'Need to delete index %s:%s in %s because it exists on dest table and does not exist on master table',
tableName,
destGSI.IndexName,
destRegion
);
params.GlobalSecondaryIndexUpdates.push({ Delete: _.pick(destGSI, 'IndexName') });
}
});
if (baseParamsAreEqual && _.isEmpty(params.GlobalSecondaryIndexUpdates)) {
// There are no updates to be made
console.log('There are no updates to be made to %s in %s', tableName, destRegion);
return false;
} else if (_.isEmpty(params.GlobalSecondaryIndexUpdates)) {
console.log('There are no GlobalSecondaryIndexUpdates to be made to %s in %s', tableName, destRegion);
delete params.GlobalSecondaryIndexUpdates;
}
return params;
},
_ensureGlobalTableConsistent: function(props) {
var tableName = props.GlobalTableName;
return this._describeGlobalTable(tableName)
.then(function(desc) {
if (desc) {
return this._updateGlobalTable(props, desc);
}
return this._createGlobalTable(props);
}.bind(this));
},
_createGlobalTable: function(props) {
return this._waitForTablesCreatingOrActive(props.GlobalTableName, _.pluck(props.ReplicationGroup, 'RegionName'))
.then(function() {
var params = _.pick(props, 'GlobalTableName', 'ReplicationGroup');
console.log('Creating global table: %j', params);
return Q.ninvoke(dynamo, 'createGlobalTable', params);
})
.then(function(resp) {
console.log('createGlobalTable response: %j', resp);
return { PhysicalResourceId: props.GlobalTableName, Arn: resp.GlobalTableDescription.GlobalTableArn };
});
},
_updateGlobalTable: function(props, desc) {
var tableName = props.GlobalTableName,
desiredRegions = _.pluck(props.ReplicationGroup, 'RegionName'),
existingRegions = _.pluck(desc.ReplicationGroup, 'RegionName'),
params = { GlobalTableName: tableName, ReplicaUpdates: [] };
console.log('Updating global table %s to match props %j', tableName, props);
console.log('The description of the current global table %s is: %j', tableName, desc);
// add missing regions:
_.each(_.difference(desiredRegions, existingRegions), function(region) {
params.ReplicaUpdates.push({ Create: { RegionName: region } });
});
// remove extra regions:
_.each(_.difference(existingRegions, desiredRegions), function(region) {
params.ReplicaUpdates.push({ Delete: { RegionName: region } });
});
if (_.isEmpty(params.ReplicaUpdates)) {
console.log('No update needed for global table %s', tableName);
return Q.when({ PhysicalResourceId: props.GlobalTableName, Arn: desc.GlobalTableArn });
}
return this._waitForTablesCreatingOrActive(tableName, desiredRegions.concat(existingRegions))
.then(function() {
console.log('Updating global table %s with params: %j', tableName, params);
return Q.ninvoke(dynamo, 'updateGlobalTable', params);
})
.then(_.constant({ PhysicalResourceId: props.GlobalTableName, Arn: desc.GlobalTableArn }));
},
_waitForTablesCreatingOrActive: function(tableName, regions) {
// Whenever you modify a global table, all of the tables in the global table
// replication group must be in either CREATING or ACTIVE state. Often when a table
// is first created it will temporarily change CREATING -> ACTIVE -> UPDATING, and
// then back to ACTIVE. If we happen to try to updateGlobalTable before the table is
// ACTIVE, we will get an error.
console.log('Waiting for %s in %s to be CREATING or ACTIVE', tableName, regions);
return Q.all(_.map(regions, function(region) {
return this._describeTableUntilState(tableName, region, [ 'CREATING', 'ACTIVE' ]);
}.bind(this)));
},
_describeGlobalTable: function(tableName) {
return Q.ninvoke(dynamo, 'describeGlobalTable', { GlobalTableName: tableName })
.then(function(resp) {
return resp.GlobalTableDescription;
})
.catch(function(err) {
if (err.code === 'GlobalTableNotFoundException') {
return false;
}
throw err;
});
},
});