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 19 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) {
// eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-extraneous-dependencies
const s3 = new (require('aws-sdk').S3)();
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not initialize this out of the handler scope into the global one? This would avoid passing of the instance to every function and also the usage of any type for the s3 client.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah right, this is a remnant of how I was mocking the s3 client previously - I'll go fix this.

Copy link
Contributor

Choose a reason for hiding this comment

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

You could do something like:

import * as AWS from 'aws-sdk';

const s3 = new AWS.S3();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So, for reasons I can't ascertain, when I hoist the const s3 = new (require(... line to the file's outer scope, or even perform a regular import like you suggested, then jest stops mocking the module properly, causing all my tests to run actual aws-sdk API calls (the first error it runs into a NoSuchBucket exception since the tests use dummy bucket names).

One thing to keep in mind is that the @aws-cdk/aws-s3 package doesn't actually have aws-sdk as a dependency. (That makes the above behavior odd - but the entire repo shares a single node_modules directory which does have aws-sdk so it's not that surprising).

Based on that, unless there is a way to fix this import behavior, I'll just keep passing the s3 client around. At the end of the day I think this is OK - I've tested that it works, and I'm not worried about any performance implications of instantiating the client inside the method since the "create" and "delete" operations only get once per bucket-and-custom-resource pair. But if you have any tips to fix this that is cool too. :)

Copy link
Contributor

Choose a reason for hiding this comment

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

The jest mocks might not be working in this new scenario because of how the actual module was imported. Previously, since the module was imported inside the handler, the jest.mock call could be placed in the describe block.

Now that the module is imported at the file level, the jest.mock should also be done at the top of the tests file (since the actual module gets imported as soon as you import the handler file).

Please give it a try once. If it works, great! Otherwise you can keep this in the current form.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, that made it work! Thank you for teaching something new. :) I did have to move the mock above the handler import in order for it to fully work, but I think this is ok.

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
250 changes: 250 additions & 0 deletions packages/@aws-cdk/aws-s3/test/auto-delete-objects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import '@aws-cdk/assert/jest';
import * as cdk from '@aws-cdk/core';
import * as s3 from '../lib';
import { handler } from '../lib/auto-delete-objects-handler/index';
Chriscbr marked this conversation as resolved.
Show resolved Hide resolved

test('when autoDeleteObjects is enabled, a custom resource is provisioned + a lambda handler for it', () => {
const stack = new cdk.Stack();

new s3.Bucket(stack, 'MyBucket', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});

expect(stack).toHaveResource('AWS::S3::Bucket');
expect(stack).toHaveResource('AWS::Lambda::Function');
expect(stack).toHaveResource('Custom::AutoDeleteObjects',
{
BucketName: { Ref: 'MyBucketF68F3FF0' },
ServiceToken: {
'Fn::GetAtt': [
'CustomAutoDeleteObjectsCustomResourceProviderHandlerE060A45A',
'Arn',
],
},
});
Chriscbr marked this conversation as resolved.
Show resolved Hide resolved
});

test('two buckets with autoDeleteObjects enabled share the same cr provider', () => {
const app = new cdk.App();
const stack = new cdk.Stack(app);

new s3.Bucket(stack, 'MyBucketOne', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
new s3.Bucket(stack, 'MyBucketTwo', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});

const template = app.synth().getStackArtifact(stack.artifactId).template;
const resourceTypes = Object.values(template.Resources).map((r: any) => r.Type).sort();

expect(resourceTypes).toStrictEqual([
// custom resource provider resources (shared)
'AWS::IAM::Role',
'AWS::Lambda::Function',

// buckets
'AWS::S3::Bucket',
'AWS::S3::Bucket',

// auto delete object resources
'Custom::AutoDeleteObjects',
'Custom::AutoDeleteObjects',
]);
});

test('when only one bucket has autoDeleteObjects enabled, only that bucket gets assigned a custom resource', () => {
const app = new cdk.App();
const stack = new cdk.Stack(app);

new s3.Bucket(stack, 'MyBucketOne', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
new s3.Bucket(stack, 'MyBucketTwo', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: false,
});

const template = app.synth().getStackArtifact(stack.artifactId).template;
const resourceTypes = Object.values(template.Resources).map((r: any) => r.Type).sort();

expect(resourceTypes).toStrictEqual([
// custom resource provider resources
'AWS::IAM::Role',
'AWS::Lambda::Function',

// buckets
'AWS::S3::Bucket',
'AWS::S3::Bucket',

// auto delete object resource
'Custom::AutoDeleteObjects',
]);

// custom resource for MyBucket1 is present
expect(stack).toHaveResource('Custom::AutoDeleteObjects',
{ BucketName: { Ref: 'MyBucketOneA6BE54C9' } });

// custom resource for MyBucket2 is not present
expect(stack).not.toHaveResource('Custom::AutoDeleteObjects',
{ BucketName: { Ref: 'MyBucketTwoC7437026' } });
});

test('iam policy is created', () => {
const stack = new cdk.Stack();

new s3.Bucket(stack, 'MyBucket', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});

expect(stack).toHaveResource('AWS::IAM::Role', {
Policies: [
{
PolicyName: 'Inline',
PolicyDocument: {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Resource: '*',
Action: [
's3:GetObject*',
's3:GetBucket*',
's3:List*',
's3:DeleteObject*',
],
},
],
},
},
],
});
});

test('throws if autoDeleteObjects is enabled but if removalPolicy is not specified', () => {
const stack = new cdk.Stack();

expect(() => new s3.Bucket(stack, 'MyBucket', { autoDeleteObjects: true })).toThrowError(/removal policy/);
});

describe('custom resource handler', () => {

const mockS3Client = {
listObjectVersions: jest.fn().mockReturnThis(),
deleteObjects: jest.fn().mockReturnThis(),
promise: jest.fn(),
};

jest.mock('aws-sdk', () => {
return { S3: jest.fn(() => mockS3Client) };
});

beforeEach(() => {
mockS3Client.deleteObjects.mockClear();
mockS3Client.listObjectVersions.mockClear();
mockS3Client.promise.mockClear();
});

test('does nothing on create event', async () => {
const event: Partial<AWSLambda.CloudFormationCustomResourceCreateEvent> = {
RequestType: 'Create',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};
await invokeHandler(event);
Copy link
Contributor

Choose a reason for hiding this comment

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

Awaiting the invokeHandler is the "WHEN" section here :)


expect(mockS3Client.listObjectVersions).not.toHaveBeenCalled();
expect(mockS3Client.deleteObjects).not.toHaveBeenCalled();
});

test('does nothing on update event', async () => {
const event: Partial<AWSLambda.CloudFormationCustomResourceUpdateEvent> = {
RequestType: 'Update',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};
await invokeHandler(event);

expect(mockS3Client.listObjectVersions).not.toHaveBeenCalled();
expect(mockS3Client.deleteObjects).not.toHaveBeenCalled();
});

test('deletes no objects on delete event when bucket has no objects', async () => {
mockS3Client.listObjectVersions().promise.mockResolvedValue({ Versions: [] }); // this counts as one call

const event: Partial<AWSLambda.CloudFormationCustomResourceDeleteEvent> = {
RequestType: 'Delete',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};
await invokeHandler(event);

expect(mockS3Client.listObjectVersions.mock.calls.length).toEqual(2);
Chriscbr marked this conversation as resolved.
Show resolved Hide resolved
expect(mockS3Client.deleteObjects).not.toHaveBeenCalled();
});

test('deletes all objects on delete event', async () => {
mockS3Client.listObjectVersions().promise.mockResolvedValue({ // this counts as one call
Versions: [
{ Key: 'Key1', VersionId: 'VersionId1' },
{ Key: 'Key2', VersionId: 'VersionId2' },
],
});

const event: Partial<AWSLambda.CloudFormationCustomResourceDeleteEvent> = {
RequestType: 'Delete',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};
await invokeHandler(event);

expect(mockS3Client.listObjectVersions.mock.calls.length).toEqual(2);
expect(mockS3Client.deleteObjects.mock.calls.length).toEqual(1);
});

test('delete event where bucket has many objects does recurse appropriately', async () => {
mockS3Client.listObjectVersions().promise.mockResolvedValueOnce({ // this counts as one call
Versions: [
{ Key: 'Key1', VersionId: 'VersionId1' },
{ Key: 'Key2', VersionId: 'VersionId2' },
],
IsTruncated: 'true',
}).mockResolvedValueOnce({
Versions: [
{ Key: 'Key3', VersionId: 'VersionId3' },
{ Key: 'Key4', VersionId: 'VersionId4' },
],
});

const event: Partial<AWSLambda.CloudFormationCustomResourceDeleteEvent> = {
RequestType: 'Delete',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};
await invokeHandler(event);

expect(mockS3Client.listObjectVersions.mock.calls.length).toEqual(3);
expect(mockS3Client.deleteObjects.mock.calls.length).toEqual(2);
});
});

// helper function to get around TypeScript expecting a complete event object,
// even though our tests require some of the fields
async function invokeHandler(event: Partial<AWSLambda.CloudFormationCustomResourceEvent>) {
return handler(event as AWSLambda.CloudFormationCustomResourceEvent);
}