cf-custom-resources/lib/bucket-cleaner.js (144 lines of code) (raw):
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
"use strict";
const { S3,HeadBucketCommand, ListObjectVersionsCommand, DeleteObjectsCommand } = require("@aws-sdk/client-s3");
// These are used for test purposes only
let defaultResponseURL;
let defaultLogGroup;
let defaultLogStream;
/**
* Upload a CloudFormation response object to S3.
*
* @param {object} event the Lambda event payload received by the handler function
* @param {object} context the Lambda context received by the handler function
* @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED'
* @param {string} physicalResourceId CloudFormation physical resource ID
* @param {object} [responseData] arbitrary response data object
* @param {string} [reason] reason for failure, if any, to convey to the user
* @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response
*/
let report = function (
event,
context,
responseStatus,
physicalResourceId,
responseData,
reason
) {
return new Promise((resolve, reject) => {
const https = require("https");
const { URL } = require("url");
var responseBody = JSON.stringify({
Status: responseStatus,
Reason: reason,
PhysicalResourceId: physicalResourceId || context.logStreamName,
StackId: event.StackId,
RequestId: event.RequestId,
LogicalResourceId: event.LogicalResourceId,
Data: responseData,
});
const parsedUrl = new URL(event.ResponseURL || defaultResponseURL);
const options = {
hostname: parsedUrl.hostname,
port: 443,
path: parsedUrl.pathname + parsedUrl.search,
method: "PUT",
headers: {
"Content-Type": "",
"Content-Length": responseBody.length,
},
};
https
.request(options)
.on("error", reject)
.on("response", (res) => {
res.resume();
if (res.statusCode >= 400) {
reject(new Error(`Error ${res.statusCode}: ${res.statusMessage}`));
} else {
resolve();
}
})
.end(responseBody, "utf8");
});
};
/**
* Delete all objects in a bucket.
*
* @param {string} bucketName Name of the bucket to be cleaned.
*/
const cleanBucket = async function (bucketName) {
const s3 = new S3();
// Make sure the bucket exists.
try {
await s3.send(new HeadBucketCommand({ Bucket: bucketName }));
} catch (err) {
if (err.name === "ResourceNotFoundException") {
return;
}
throw err;
}
const listObjectVersionsParam = {
Bucket: bucketName
}
while (true) {
const listResp = await s3.send(new ListObjectVersionsCommand(listObjectVersionsParam));
// After deleting other versions, remove delete markers version.
// For info on "delete marker": https://docs.aws.amazon.com/AmazonS3/latest/dev/DeleteMarker.html
let objectsToDelete = [
...listResp.Versions.map(version => ({ Key: version.Key, VersionId: version.VersionId })),
...listResp.DeleteMarkers.map(marker => ({ Key: marker.Key, VersionId: marker.VersionId }))
];
if (objectsToDelete.length === 0) {
return
}
const delResp = await s3.send(new DeleteObjectsCommand({
Bucket: bucketName,
Delete: {
Objects: objectsToDelete,
Quiet: true
}
}));
if (delResp.Errors.length > 0) {
throw new AggregateError([new Error(`${delResp.Errors.length}/${objectsToDelete.length} objects failed to delete`),
new Error(`first failed on key "${delResp.Errors[0].Key}": ${delResp.Errors[0].Message}`)]);
}
if (!listResp.IsTruncated) {
return
}
listObjectVersionsParam.KeyMarker = listResp.NextKeyMarker
listObjectVersionsParam.VersionIdMarker = listResp.NextVersionIdMarker
}
};
/**
* Correct desired count handler, invoked by Lambda.
*/
exports.handler = async function (event, context) {
var responseData = {};
const props = event.ResourceProperties;
const physicalResourceId = event.PhysicalResourceId || `bucket-cleaner-${event.LogicalResourceId}`;
try {
switch (event.RequestType) {
case "Create":
case "Update":
break;
case "Delete":
await cleanBucket(props.BucketName);
break;
default:
throw new Error(`Unsupported request type ${event.RequestType}`);
}
await report(event, context, "SUCCESS", physicalResourceId, responseData);
} catch (err) {
console.log(`Caught error ${err}.`);
await report(
event,
context,
"FAILED",
physicalResourceId,
null,
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${defaultLogStream || context.logStreamName
})`
);
}
};
/**
* @private
*/
exports.withDefaultResponseURL = function (url) {
defaultResponseURL = url;
};
/**
* @private
*/
exports.withDefaultLogStream = function (logStream) {
defaultLogStream = logStream;
};
/**
* @private
*/
exports.withDefaultLogGroup = function (logGroup) {
defaultLogGroup = logGroup;
};
class AggregateError extends Error {
#errors;
name = "AggregateError";
constructor(errors) {
let message = errors
.map(error =>
String(error),
)
.join("\n");
super(message);
this.#errors = errors;
}
get errors() {
return [...this.#errors];
}
}