UNPKG

@collaborne/custom-cloudformation-resources

Version:
228 lines 11.2 kB
"use strict"; 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