packages/@aws-cdk/custom-resource-handlers/lib/nodejs-entrypoint.ts (137 lines of code) (raw):
import * as https from 'https';
import * as url from 'url';
// for unit tests
export const external = {
sendHttpRequest: defaultSendHttpRequest,
log: defaultLog,
includeStackTraces: true,
userHandlerIndex: './index',
};
const CREATE_FAILED_PHYSICAL_ID_MARKER = 'AWSCDK::CustomResourceProviderFramework::CREATE_FAILED';
const MISSING_PHYSICAL_ID_MARKER = 'AWSCDK::CustomResourceProviderFramework::MISSING_PHYSICAL_ID';
export type Response = AWSLambda.CloudFormationCustomResourceEvent & HandlerResponse;
export type Handler = (event: AWSLambda.CloudFormationCustomResourceEvent, context?: AWSLambda.Context) => Promise<HandlerResponse | void>;
export type HandlerResponse = undefined | {
Data?: any;
PhysicalResourceId?: string;
Reason?: string;
NoEcho?: boolean;
};
export function makeHandler(userHandler: Handler) {
return async (event: AWSLambda.CloudFormationCustomResourceEvent, context?: AWSLambda.Context) => {
const sanitizedEvent = { ...event, ResponseURL: '...' };
external.log(JSON.stringify(sanitizedEvent, undefined, 2));
// ignore DELETE event when the physical resource ID is the marker that
// indicates that this DELETE is a subsequent DELETE to a failed CREATE
// operation.
if (event.RequestType === 'Delete' && event.PhysicalResourceId === CREATE_FAILED_PHYSICAL_ID_MARKER) {
external.log('ignoring DELETE event caused by a failed CREATE event');
await submitResponse('SUCCESS', event);
return;
}
try {
// invoke the user handler. this is intentionally inside the try-catch to
// ensure that if there is an error it's reported as a failure to
// cloudformation (otherwise cfn waits).
const result = await userHandler(sanitizedEvent, context);
// validate user response and create the combined event
const responseEvent = renderResponse(event, result);
// submit to cfn as success
await submitResponse('SUCCESS', responseEvent);
} catch (e: any) {
const resp: Response = {
...event,
Reason: external.includeStackTraces ? e.stack : e.message,
};
if (!resp.PhysicalResourceId) {
// special case: if CREATE fails, which usually implies, we usually don't
// have a physical resource id. in this case, the subsequent DELETE
// operation does not have any meaning, and will likely fail as well. to
// address this, we use a marker so the provider framework can simply
// ignore the subsequent DELETE.
if (event.RequestType === 'Create') {
external.log('CREATE failed, responding with a marker physical resource id so that the subsequent DELETE will be ignored');
resp.PhysicalResourceId = CREATE_FAILED_PHYSICAL_ID_MARKER;
} else {
// otherwise, if PhysicalResourceId is not specified, something is
// terribly wrong because all other events should have an ID.
external.log(`ERROR: Malformed event. "PhysicalResourceId" is required: ${JSON.stringify(event)}`);
}
}
// this is an actual error, fail the activity altogether and exist.
await submitResponse('FAILED', resp);
}
};
}
function renderResponse(
cfnRequest: AWSLambda.CloudFormationCustomResourceEvent & { PhysicalResourceId?: string },
handlerResponse: void | HandlerResponse = { }): Response {
// if physical ID is not returned, we have some defaults for you based
// on the request type.
const physicalResourceId = handlerResponse.PhysicalResourceId ?? cfnRequest.PhysicalResourceId ?? cfnRequest.RequestId;
// if we are in DELETE and physical ID was changed, it's an error.
if (cfnRequest.RequestType === 'Delete' && physicalResourceId !== cfnRequest.PhysicalResourceId) {
throw new Error(`DELETE: cannot change the physical resource ID from "${cfnRequest.PhysicalResourceId}" to "${handlerResponse.PhysicalResourceId}" during deletion`);
}
// merge request event and result event (result prevails).
return {
...cfnRequest,
...handlerResponse,
PhysicalResourceId: physicalResourceId,
};
}
async function submitResponse(status: 'SUCCESS' | 'FAILED', event: Response) {
const json: AWSLambda.CloudFormationCustomResourceResponse = {
Status: status,
Reason: event.Reason ?? status,
StackId: event.StackId,
RequestId: event.RequestId,
PhysicalResourceId: event.PhysicalResourceId || MISSING_PHYSICAL_ID_MARKER,
LogicalResourceId: event.LogicalResourceId,
NoEcho: event.NoEcho,
Data: event.Data,
};
const parsedUrl = url.parse(event.ResponseURL);
const loggingSafeUrl = `${parsedUrl.protocol}//${parsedUrl.hostname}/${parsedUrl.pathname}?***`;
external.log('submit response to cloudformation', loggingSafeUrl, json);
const responseBody = JSON.stringify(json);
const req = {
hostname: parsedUrl.hostname,
path: parsedUrl.path,
method: 'PUT',
headers: {
'content-type': '',
'content-length': Buffer.byteLength(responseBody, 'utf8'),
},
};
const retryOptions = {
attempts: 5,
sleep: 1000,
};
await withRetries(retryOptions, external.sendHttpRequest)(req, responseBody);
}
async function defaultSendHttpRequest(options: https.RequestOptions, requestBody: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
try {
const request = https.request(options, (response) => {
response.resume(); // Consume the response but don't care about it
if (!response.statusCode || response.statusCode >= 400) {
reject(new Error(`Unsuccessful HTTP response: ${response.statusCode}`));
} else {
resolve();
}
});
request.on('error', reject);
request.write(requestBody);
request.end();
} catch (e) {
reject(e);
}
});
}
function defaultLog(fmt: string, ...params: any[]) {
// eslint-disable-next-line no-console
console.log(fmt, ...params);
}
export interface RetryOptions {
/** How many retries (will at least try once) */
readonly attempts: number;
/** Sleep base, in ms */
readonly sleep: number;
}
export function withRetries<A extends Array<any>, B>(options: RetryOptions, fn: (...xs: A) => Promise<B>): (...xs: A) => Promise<B> {
return async (...xs: A) => {
let attempts = options.attempts;
let ms = options.sleep;
while (true) {
try {
return await fn(...xs);
} catch (e) {
if (attempts-- <= 0) {
throw e;
}
await sleep(Math.floor(Math.random() * ms));
ms *= 2;
}
}
};
}
async function sleep(ms: number): Promise<void> {
return new Promise((ok) => setTimeout(ok, ms));
}