Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(s3): add option to auto delete objects upon bucket removal #9751

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
09642e9
feat(s3): add option to auto delete objects upon bucket removal
Chriscbr Aug 16, 2020
f3b9cd3
Merge branch 'master' into feat/clear-s3-bucket
Chriscbr Aug 16, 2020
6b27429
Merge branch 'master' into feat/clear-s3-bucket
Chriscbr Oct 13, 2020
2d42b2e
Refactor to use CustomResourceProvider
Chriscbr Oct 13, 2020
56a48d7
Update unit tests
Chriscbr Oct 13, 2020
809ea22
Add feedback from jogold
Chriscbr Oct 18, 2020
83463a4
Add unit tests for handler and ServiceToken
Chriscbr Oct 18, 2020
4018b4d
Merge branch 'master' into feat/clear-s3-bucket
Chriscbr Oct 18, 2020
6ff4bec
revert changes to handler permissions since it is singleton
Chriscbr Oct 18, 2020
885b178
add unit test
Chriscbr Oct 18, 2020
60c21f4
Merge branch 'master' into feat/clear-s3-bucket
Chriscbr Oct 18, 2020
00f1b65
remove todo comment
Chriscbr Oct 18, 2020
6762518
remove unneeded PutObject permissions
Chriscbr Oct 20, 2020
92e1b9c
Merge branch 'master' into feat/clear-s3-bucket
Chriscbr Oct 20, 2020
60c16ca
Merge branch 'master' into feat/clear-s3-bucket
Chriscbr Nov 7, 2020
2d41d98
convert test file to jest
Chriscbr Nov 7, 2020
4e2e2ff
rewrite mocking with jest to remove sinon dependency
Chriscbr Nov 7, 2020
1059325
refactor tests to mock S3 client cleaner, add one test
Chriscbr Nov 7, 2020
f399f67
Merge branch 'master' into feat/clear-s3-bucket
Chriscbr Nov 8, 2020
99ab9c1
fixup tests and other nitpicks
Chriscbr Nov 8, 2020
d35592a
hoist aws-sdk import to global scope
Chriscbr Nov 8, 2020
21f5a31
reorder imports
Chriscbr Nov 8, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/@aws-cdk/aws-s3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,20 @@ bucket.urlForObject('objectname'); // Path-Style URL
bucket.virtualHostedUrlForObject('objectname'); // Virtual Hosted-Style URL
bucket.virtualHostedUrlForObject('objectname', { regional: false }); // Virtual Hosted-Style URL but non-regional
```

### Bucket deletion

When a bucket is removed from a stack (or the stack is deleted), the S3
bucket will be removed according to its removal policy (which by default will
simply orphan the bucket and leave it in your AWS account). If the removal
policy is set to `RemovalPolicy.DESTROY`, the bucket will be deleted as long
as it does not contain any objects.

To override this and force all objects to get deleted during bucket deletion,
enable the`autoDeleteObjects` option.

```ts
const bucket = new Bucket(this, 'MyTempFileBucket', {
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
iliapolo marked this conversation as resolved.
Show resolved Hide resolved
});
43 changes: 43 additions & 0 deletions packages/@aws-cdk/aws-s3/lib/auto-delete-objects-handler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent, s3?: any) {
Chriscbr marked this conversation as resolved.
Show resolved Hide resolved
// eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-extraneous-dependencies
if (!s3) { s3 = new (require('aws-sdk').S3)(); }
if (event.RequestType === 'Create') { return onCreate(s3, event); }
if (event.RequestType === 'Update') { return onUpdate(s3, event); }
Chriscbr marked this conversation as resolved.
Show resolved Hide resolved
if (event.RequestType === 'Delete') { return onDelete(s3, event); }
throw new Error('Invalid request type.');
}

/**
* Recursively delete all items in the bucket
*
* @param {AWS.S3} s3 the S3 client
* @param {*} bucketName the bucket name
*/
async function emptyBucket(s3: any, bucketName: string) {
const listedObjects = await s3.listObjectVersions({ Bucket: bucketName }).promise();
const contents = (listedObjects.Versions || []).concat(listedObjects.DeleteMarkers || []);
if (contents.length === 0) {
return;
};

let records = contents.map((record: any) => ({ Key: record.Key, VersionId: record.VersionId }));
Chriscbr marked this conversation as resolved.
Show resolved Hide resolved
await s3.deleteObjects({ Bucket: bucketName, Delete: { Objects: records } }).promise();

if (listedObjects?.IsTruncated === 'true' ) await emptyBucket(s3, bucketName);
}

async function onCreate(_s3: any, _event: AWSLambda.CloudFormationCustomResourceCreateEvent) {
return;
}

async function onUpdate(_s3: any, _event: AWSLambda.CloudFormationCustomResourceUpdateEvent) {
return;
}

async function onDelete(s3: any, deleteEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent) {
const bucketName = deleteEvent.ResourceProperties?.BucketName;
if (!bucketName) {
throw new Error('No BucketName was provided.');
}
await emptyBucket(s3, bucketName);
}
46 changes: 45 additions & 1 deletion packages/@aws-cdk/aws-s3/lib/bucket.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { EOL } from 'os';
import * as path from 'path';
import * as events from '@aws-cdk/aws-events';
import * as iam from '@aws-cdk/aws-iam';
import * as kms from '@aws-cdk/aws-kms';
import { Fn, IResource, Lazy, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/core';
import { Fn, IResource, Lazy, RemovalPolicy, Resource, Stack, Token, CustomResource, CustomResourceProvider, CustomResourceProviderRuntime } from '@aws-cdk/core';
import { Construct } from 'constructs';
import { BucketPolicy } from './bucket-policy';
import { IBucketNotificationDestination } from './destination';
Expand All @@ -12,6 +13,8 @@ import { LifecycleRule } from './rule';
import { CfnBucket } from './s3.generated';
import { parseBucketArn, parseBucketName } from './util';

const AUTO_DELETE_OBJECTS_RESOURCE_TYPE = 'Custom::AutoDeleteObjects';

export interface IBucket extends IResource {
/**
* The ARN of the bucket.
Expand Down Expand Up @@ -1026,6 +1029,16 @@ export interface BucketProps {
*/
readonly removalPolicy?: RemovalPolicy;

/**
* Whether all objects should be automatically deleted when the bucket is
* removed from the stack.
*
* Requires the removal policy to be set to destroy.
*
* @default false
*/
readonly autoDeleteObjects?: boolean;

/**
* Whether this bucket should have versioning turned on or not.
*
Expand Down Expand Up @@ -1301,6 +1314,20 @@ export class Bucket extends BucketBase {
if (props.publicReadAccess) {
this.grantPublicAccess();
}

if (props.autoDeleteObjects) {
if (props.removalPolicy !== RemovalPolicy.DESTROY) {
throw new Error("Cannot use 'autoDeleteObjects' property on a bucket without setting removal policy to 'destroy'.");
}

new CustomResource(this, 'AutoDeleteObjectsResource', {
resourceType: AUTO_DELETE_OBJECTS_RESOURCE_TYPE,
serviceToken: this.getOrCreateAutoDeleteObjectsResource(),
properties: {
BucketName: this.bucketName,
},
});
}
}

/**
Expand Down Expand Up @@ -1692,6 +1719,23 @@ export class Bucket extends BucketBase {
};
});
}

private getOrCreateAutoDeleteObjectsResource() {
return CustomResourceProvider.getOrCreate(this, AUTO_DELETE_OBJECTS_RESOURCE_TYPE, {
codeDirectory: path.join(__dirname, 'auto-delete-objects-handler'),
runtime: CustomResourceProviderRuntime.NODEJS_12,
policyStatements: [
{
Effect: 'Allow',
Resource: '*',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Resource: '*',
Resource: this.bucketArn,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out this policy for this lambda needs to have access to all buckets in the stack, since it is a singleton that is shared with the entire stack. So I think specifying either * or arn:aws:s3:::* should work here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think this is a very important consideration we should think about. I'm not comfortable with creating a lambda function that has permissions to delete all objects from all buckets.

I think that it's better to create a provider per bucket, which i know is currently not possible, but maybe we should add it.

@rix0rrr What do you think about either making the constructor public, or allow passing a scope?

Copy link
Contributor

@jogold jogold Oct 22, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we use a bucket policy here instead? (a policy that would allow the role of the custom resource to act on the bucket)

Action: [
...perms.BUCKET_READ_ACTIONS,
...perms.BUCKET_DELETE_ACTIONS,
],
},
],
});
}
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-s3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
"cdk-integ-tools": "0.0.0",
"cfn2ts": "0.0.0",
"nodeunit": "^0.11.3",
"pkglint": "0.0.0"
"pkglint": "0.0.0",
"sinon": "9.2.0"
},
"dependencies": {
"@aws-cdk/aws-events": "0.0.0",
Expand Down
Loading