@collaborne/custom-cloudformation-resources
Version:
Custom CloudFormation resources
228 lines • 11.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.CustomResource = void 0;
const aws_sdk_1 = require("aws-sdk");
const crc_1 = require("crc");
const cfn_response_1 = require("./cfn-response");
const {
/** ARN of the role to use with CloudWatch Events */
CW_EVENTS_CONTINUATION_RULE_ROLE_ARN,
/**
* ARN of the optional role to use by CloudWatch Events to use the service token
*
* From the `PutTargets` documentation:
* > To be able to make API calls against the resources that you own, Amazon EventBridge needs
* > the appropriate permissions. For Lambda and Amazon SNS resources, EventBridge relies on
* > resource-based policies. For EC2 instances, Kinesis Data Streams, Step Functions state
* > machines and API Gateway REST APIs, EventBridge relies on IAM roles that you specify in the
* > RoleARN argument in PutTargets. For more information, see Authentication and Access Control
* > in the Amazon EventBridge User Guide .
*/
CW_EVENTS_CONTINUATION_TARGET_ROLE_ARN, } = process.env;
function isContinuationRequired(response) {
return typeof response === 'object' && 'continuationAfter' in response;
}
function isContinuedCustomResourceRequest(request) {
return ('ContinuationAttributes' in request &&
typeof request.ContinuationAttributes === 'object');
}
class CustomResource {
constructor(schema, logicalResourceId, logger) {
this.schema = schema;
this.logicalResourceId = logicalResourceId;
this.logger = logger;
this.cwEvents = new aws_sdk_1.CloudWatchEvents({ apiVersion: '2015-10-07' });
this.requestQueue = [];
}
async handleRequest(request) {
try {
this.currentRequest = request;
return await this.handleRequestInner(request);
}
finally {
this.currentRequest = undefined;
}
}
/**
* Get the request that is currently being processed
*
* This is mostly intended for integrations that want to watch the state of their
* resources, the returned value should not be changed in any way.
*
* @returns the current request when called during an execution of {@link handleRequest}, otherwise `undefined`
*/
get request() {
return this.currentRequest;
}
handleRequestInner(request) {
let resolveRequest;
let rejectRequest;
const result = new Promise((resolve, reject) => {
resolveRequest = resolve;
rejectRequest = reject;
});
const processThisRequest = async () => {
try {
const { status } = await this.processRequest(request);
resolveRequest(status);
}
catch (err) {
rejectRequest(err);
}
finally {
// Remove ourselves from the queue, and execute the next one.
this.requestQueue.shift();
const processNextRequest = this.requestQueue.length > 0 ? this.requestQueue[0] : undefined;
if (processNextRequest) {
void processNextRequest();
}
}
};
if (this.requestQueue.push(processThisRequest) === 1) {
// What we added is the first entry, so start executing it.
void processThisRequest();
}
return result;
}
async processRequest(request) {
// TODO: validate the parameters to conform to the schema
// Default physical resource id
const physicalResourceId = request.PhysicalResourceId ||
[request.StackId, request.LogicalResourceId, request.RequestId].join('/');
let continuationAttributes;
if (isContinuedCustomResourceRequest(request)) {
this.logger.log('Request is a continuation of an earlier request');
continuationAttributes = request.ContinuationAttributes;
}
let status = 'FAILED';
let statusReason;
let response;
try {
const { ServiceToken: _ignoredServiceToken, ...properties } = request.ResourceProperties;
switch (request.RequestType) {
case 'Create':
response = await this.createResource(physicalResourceId, properties, continuationAttributes);
break;
case 'Delete':
response = await this.deleteResource(physicalResourceId, properties, continuationAttributes);
break;
case 'Update':
{
// Note that the old properties could be a completely different schema. It's the job of the developer
// of the template to prevent/handle that.
const { ServiceToken: _ignoredOldServiceToken, ...oldProperties } = request.OldResourceProperties;
response = await this.updateResource(physicalResourceId, properties, oldProperties, continuationAttributes);
}
break;
}
status = 'SUCCESS';
}
catch (err) {
this.logger.warn(`Uncaught error when handling request "${request.RequestType}": ${err.message}`);
statusReason = err.message;
}
if (isContinuationRequired(response)) {
// Schedule the invocation of the function again, with the additional attributes from
// the response
const { continuationAfter, continuationAttributes } = response;
const continuationRuleName = this.getContinuationRuleName(request);
const ruleRoleArn = CW_EVENTS_CONTINUATION_RULE_ROLE_ARN;
const targetRoleArn = CW_EVENTS_CONTINUATION_TARGET_ROLE_ARN;
// Ideally we want to put the target for the continuation first so that we don't miss
// our own goal, but CWE doesn't work this way.
// What does work however is to create the rule with a schedule expression pointing to a
// day in the past, keep it "DISABLED", then add the target, and then update the rule to
// enable it with a suitably-in-the-future expression.
// Note that if the rule already exists this will similarly first disable it; as the rule name
// and target ID are constant over the life-time of the rule this should all be idempotent.
const prepPutRuleParams = {
Name: continuationRuleName,
RoleArn: ruleRoleArn,
ScheduleExpression: `cron(30 7 19 6 ? 2018)`,
State: 'DISABLED',
};
await this.cwEvents.putRule(prepPutRuleParams).promise();
const putTargetsParams = {
Rule: continuationRuleName,
Targets: [
{
Arn: request.ServiceToken,
Id: CustomResource.CW_EVENTS_TARGET_ID,
Input: JSON.stringify({
...request,
ContinuationAttributes: continuationAttributes,
}),
RoleArn: targetRoleArn,
},
],
};
await this.cwEvents.putTargets(putTargetsParams).promise();
const now = new Date();
const whenTs = Math.ceil((now.getTime() / 1000 + continuationAfter) / 60) * 60 * 1000;
// Bump up to the next full minute, as we won't be getting scheduling if the time isn't "far enough"
// in the future.
const when = new Date(whenTs);
if (when.getMinutes() === now.getMinutes()) {
when.setMinutes(when.getMinutes() + 1);
}
// Build a cron expression for CWE
// See also https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html
const cronExpression = `${when.getUTCMinutes()} ${when.getUTCHours()} ${when.getUTCDate()} ${when.getUTCMonth() + 1} ? ${when.getUTCFullYear()}`;
this.logger.log(`Scheduling continuation using CWE rule ${continuationRuleName} after ${continuationAfter}s (at ${cronExpression})`);
const schedulePutRuleParams = {
Name: continuationRuleName,
RoleArn: ruleRoleArn,
ScheduleExpression: `cron(${cronExpression})`,
State: 'ENABLED',
};
await this.cwEvents.putRule(schedulePutRuleParams).promise();
}
else {
// A "definite" status, so write that into the response document
const responsePhysicalResourceId = (response === null || response === void 0 ? void 0 : response.physicalResourceId) || physicalResourceId;
await cfn_response_1.send(request, status, statusReason, responsePhysicalResourceId, response === null || response === void 0 ? void 0 : response.attributes);
// If there are continuation attributes in the request, we know that this was a continuation
// and therefore we now want to remove the rule.
if (continuationAttributes) {
const continuationRuleName = this.getContinuationRuleName(request);
this.logger.log(`Cleaning up CWE rule ${continuationRuleName}`);
try {
await this.cwEvents
.removeTargets({
Rule: continuationRuleName,
Ids: [CustomResource.CW_EVENTS_TARGET_ID],
})
.promise();
await this.cwEvents
.deleteRule({
Name: continuationRuleName,
})
.promise();
}
catch (err) {
// Best effort, didn't work, bad luck.
// Given that the rule is a single date, this should merely produce garbage in CW Events, but not produce
// any other side-effects.
this.logger.warn(`Cannot remove continuation rule ${continuationRuleName}: ${err.message}`);
}
}
}
return {
status,
};
}
getContinuationRuleName(request) {
// Rule name, can be at most 64 characters and has a limited range of valid characters
// We also know the rule must be unique for this request, so we try to cram as much as we can
// into it.
// StackId is something like "arn:aws:cloudformation:us-west-2:123456789012:stack/stack-name/guid", for our purposes
// we care only about the stack name.
const [, stackName] = request.StackId.split(/:/)[5].split(/\//);
const safeRequestId = crc_1.crc32(request.RequestId).toString(16);
const continuationRuleName = `Continuation-${stackName}-${request.LogicalResourceId}`;
return `${continuationRuleName.slice(0, 55)}-${safeRequestId}`;
}
}
exports.CustomResource = CustomResource;
CustomResource.CW_EVENTS_TARGET_ID = 'CustomResource';
//# sourceMappingURL=custom-resource.js.map