Skip to content

Commit 4b7ff58

Browse files
committed
feat(ecr): add option to auto delete images upon ECR repo removal
1 parent a04c017 commit 4b7ff58

6 files changed

Lines changed: 529 additions & 1 deletion

File tree

packages/@aws-cdk/aws-ecr/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,21 @@ is important here):
9494
repository.addLifecycleRule({ tagPrefixList: ['prod'], maxImageCount: 9999 });
9595
repository.addLifecycleRule({ maxImageAge: cdk.Duration.days(30) });
9696
```
97+
98+
### Repository deletion
99+
100+
When a repository is removed from a stack (or the stack is deleted), the ECR
101+
repository will be removed according to its removal policy (which by default will
102+
simply orphan the repository and leave it in your AWS account). If the removal
103+
policy is set to `RemovalPolicy.DESTROY`, the repository will be deleted as long
104+
as it does not contain any images.
105+
106+
To override this and force all images to get deleted during repository deletion,
107+
enable the`autoDeleteImages` option.
108+
109+
```ts
110+
const repository = new Repository(this, 'MyTempRepo', {
111+
removalPolicy: RemovalPolicy.DESTROY,
112+
autoDeleteImages: true,
113+
});
114+
```
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// eslint-disable-next-line import/no-extraneous-dependencies
2+
import { ECR } from 'aws-sdk';
3+
4+
const ecr = new ECR();
5+
6+
export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
7+
switch (event.RequestType) {
8+
case 'Create':
9+
case 'Update':
10+
return;
11+
case 'Delete':
12+
return onDelete(event);
13+
}
14+
}
15+
16+
/**
17+
* Recursively delete all images in the repository
18+
*
19+
* @param ECR.ListImagesRequest the repositoryName & nextToken if presented
20+
*/
21+
async function emptyRepository(params: ECR.ListImagesRequest) {
22+
const listedImages = await ecr.listImages(params).promise();
23+
const imageIds = listedImages?.imageIds ?? [];
24+
const nextToken = listedImages.nextToken ?? null;
25+
if (imageIds.length === 0) {
26+
return;
27+
}
28+
29+
await ecr.batchDeleteImage({
30+
repositoryName: params.repositoryName,
31+
imageIds,
32+
}).promise();
33+
34+
if (nextToken) {
35+
await emptyRepository({
36+
...params,
37+
nextToken,
38+
});
39+
}
40+
}
41+
42+
async function onDelete(deleteEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent) {
43+
const repositoryName = deleteEvent.ResourceProperties?.RepositoryName;
44+
if (!repositoryName) {
45+
throw new Error('No RepositoryName was provided.');
46+
}
47+
await emptyRepository({ repositoryName });
48+
}

packages/@aws-cdk/aws-ecr/lib/repository.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { EOL } from 'os';
2+
import * as path from 'path';
23
import * as events from '@aws-cdk/aws-events';
34
import * as iam from '@aws-cdk/aws-iam';
4-
import { IResource, Lazy, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/core';
5+
import {
6+
IResource, Lazy, RemovalPolicy, Resource, Stack, Token,
7+
CustomResource, CustomResourceProvider, CustomResourceProviderRuntime,
8+
} from '@aws-cdk/core';
59
import { IConstruct, Construct } from 'constructs';
610
import { CfnRepository } from './ecr.generated';
711
import { LifecycleRule, TagStatus } from './lifecycle';
@@ -349,6 +353,17 @@ export interface RepositoryProps {
349353
*/
350354
readonly removalPolicy?: RemovalPolicy;
351355

356+
/**
357+
* Whether all images should be automatically deleted when the repository is
358+
* removed from the stack or when the stack is deleted.
359+
*
360+
* Requires the `removalPolicy` to be set to `RemovalPolicy.DESTROY`.
361+
*
362+
* @default false
363+
*/
364+
readonly autoDeleteImages?: boolean;
365+
366+
352367
/**
353368
* Enable the scan on push when creating the repository
354369
*
@@ -441,6 +456,7 @@ export class Repository extends RepositoryBase {
441456
});
442457
}
443458

459+
private static readonly AUTO_DELETE_IMAGES_RESOURCE_TYPE = 'Custom::ECRAutoDeleteImages';
444460

445461
private static validateRepositoryName(physicalName: string) {
446462
const repositoryName = physicalName;
@@ -496,6 +512,12 @@ export class Repository extends RepositoryBase {
496512
if (props.lifecycleRules) {
497513
props.lifecycleRules.forEach(this.addLifecycleRule.bind(this));
498514
}
515+
if (props.autoDeleteImages) {
516+
if (props.removalPolicy !== RemovalPolicy.DESTROY) {
517+
throw new Error('Cannot use \'autoDeleteImages\' property on a repository without setting removal policy to \'DESTROY\'.');
518+
}
519+
this.enableAutoDeleteImages();
520+
}
499521

500522
this.repositoryName = this.getResourceNameAttribute(resource.ref);
501523
this.repositoryArn = this.getResourceArnAttribute(resource.attrArn, {
@@ -600,6 +622,35 @@ export class Repository extends RepositoryBase {
600622
validateAnyRuleLast(ret);
601623
return ret;
602624
}
625+
626+
private enableAutoDeleteImages() {
627+
const provider = CustomResourceProvider.getOrCreateProvider(this, Repository.AUTO_DELETE_IMAGES_RESOURCE_TYPE, {
628+
codeDirectory: path.join(__dirname, 'auto-delete-images-handler'),
629+
runtime: CustomResourceProviderRuntime.NODEJS_14_X,
630+
});
631+
632+
// Use a iam policy to allow the custom resource to list & delete
633+
// images in the repository
634+
this.addToResourcePolicy(new iam.PolicyStatement({
635+
actions: [
636+
'ecr:BatchDeleteImage',
637+
'ecr:ListImages',
638+
],
639+
resources: [
640+
this.repositoryArn,
641+
],
642+
principals: [new iam.ArnPrincipal(provider.roleArn)],
643+
}));
644+
645+
const customResource = new CustomResource(this, 'AutoDeleteImagesCustomResource', {
646+
resourceType: Repository.AUTO_DELETE_IMAGES_RESOURCE_TYPE,
647+
serviceToken: provider.serviceToken,
648+
properties: {
649+
RepositoryName: Lazy.any({ produce: () => this.repositoryName }),
650+
},
651+
});
652+
customResource.node.addDependency(this);
653+
}
603654
}
604655

605656
function validateAnyRuleLast(rules: LifecycleRule[]) {
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
const mockECRClient = {
2+
listImages: jest.fn().mockReturnThis(),
3+
batchDeleteImage: jest.fn().mockReturnThis(),
4+
promise: jest.fn(),
5+
};
6+
7+
import { handler } from '../lib/auto-delete-images-handler';
8+
9+
jest.mock('aws-sdk', () => {
10+
return { ECR: jest.fn(() => mockECRClient) };
11+
});
12+
13+
beforeEach(() => {
14+
mockECRClient.listImages.mockReturnThis();
15+
mockECRClient.batchDeleteImage.mockReturnThis();
16+
});
17+
18+
afterEach(() => {
19+
jest.resetAllMocks();
20+
});
21+
22+
test('does nothing on create event', async () => {
23+
// GIVEN
24+
const event: Partial<AWSLambda.CloudFormationCustomResourceCreateEvent> = {
25+
RequestType: 'Create',
26+
ResourceProperties: {
27+
ServiceToken: 'Foo',
28+
RepositoryName: 'MyRepo',
29+
},
30+
};
31+
32+
// WHEN
33+
await invokeHandler(event);
34+
35+
// THEN
36+
expect(mockECRClient.listImages).toHaveBeenCalledTimes(0);
37+
expect(mockECRClient.batchDeleteImage).toHaveBeenCalledTimes(0);
38+
});
39+
40+
test('does nothing on update event', async () => {
41+
// GIVEN
42+
const event: Partial<AWSLambda.CloudFormationCustomResourceUpdateEvent> = {
43+
RequestType: 'Update',
44+
ResourceProperties: {
45+
ServiceToken: 'Foo',
46+
RepositoryName: 'MyRepo',
47+
},
48+
};
49+
50+
// WHEN
51+
await invokeHandler(event);
52+
53+
// THEN
54+
expect(mockECRClient.listImages).toHaveBeenCalledTimes(0);
55+
expect(mockECRClient.batchDeleteImage).toHaveBeenCalledTimes(0);
56+
});
57+
58+
test('deletes no images on delete event when repository has no images', async () => {
59+
// GIVEN
60+
mockECRClient.promise.mockResolvedValue({ imageIds: [] }); // listedImages() call
61+
62+
// WHEN
63+
const event: Partial<AWSLambda.CloudFormationCustomResourceDeleteEvent> = {
64+
RequestType: 'Delete',
65+
ResourceProperties: {
66+
ServiceToken: 'Foo',
67+
RepositoryName: 'MyRepo',
68+
},
69+
};
70+
await invokeHandler(event);
71+
72+
// THEN
73+
expect(mockECRClient.listImages).toHaveBeenCalledTimes(1);
74+
expect(mockECRClient.listImages).toHaveBeenCalledWith({ repositoryName: 'MyRepo' });
75+
expect(mockECRClient.batchDeleteImage).toHaveBeenCalledTimes(0);
76+
});
77+
78+
test('deletes all images on delete event', async () => {
79+
mockECRClient.promise.mockResolvedValue({ // listedImages() call
80+
imageIds: [
81+
{
82+
imageTag: 'tag1',
83+
imageDigest: 'sha256-1',
84+
},
85+
{
86+
imageTag: 'tag2',
87+
imageDigest: 'sha256-2',
88+
},
89+
],
90+
});
91+
92+
// WHEN
93+
const event: Partial<AWSLambda.CloudFormationCustomResourceDeleteEvent> = {
94+
RequestType: 'Delete',
95+
ResourceProperties: {
96+
ServiceToken: 'Foo',
97+
RepositoryName: 'MyRepo',
98+
},
99+
};
100+
await invokeHandler(event);
101+
102+
// THEN
103+
expect(mockECRClient.listImages).toHaveBeenCalledTimes(1);
104+
expect(mockECRClient.listImages).toHaveBeenCalledWith({ repositoryName: 'MyRepo' });
105+
expect(mockECRClient.batchDeleteImage).toHaveBeenCalledTimes(1);
106+
expect(mockECRClient.batchDeleteImage).toHaveBeenCalledWith({
107+
repositoryName: 'MyRepo',
108+
imageIds: [
109+
{
110+
imageTag: 'tag1',
111+
imageDigest: 'sha256-1',
112+
},
113+
{
114+
imageTag: 'tag2',
115+
imageDigest: 'sha256-2',
116+
},
117+
],
118+
});
119+
});
120+
121+
test('delete event where repo has many images does recurse appropriately', async () => {
122+
// GIVEN
123+
mockECRClient.promise // listedImages() call
124+
.mockResolvedValueOnce({
125+
imageIds: [
126+
{
127+
imageTag: 'tag1',
128+
imageDigest: 'sha256-1',
129+
},
130+
{
131+
imageTag: 'tag2',
132+
imageDigest: 'sha256-2',
133+
},
134+
],
135+
nextToken: 'token1',
136+
})
137+
.mockResolvedValueOnce(undefined) // batchDeleteImage() call
138+
.mockResolvedValueOnce({ // listedImages() call
139+
imageIds: [
140+
{
141+
imageTag: 'tag3',
142+
imageDigest: 'sha256-3',
143+
},
144+
{
145+
imageTag: 'tag4',
146+
imageDigest: 'sha256-4',
147+
},
148+
],
149+
});
150+
151+
// WHEN
152+
const event: Partial<AWSLambda.CloudFormationCustomResourceDeleteEvent> = {
153+
RequestType: 'Delete',
154+
ResourceProperties: {
155+
ServiceToken: 'Foo',
156+
RepositoryName: 'MyRepo',
157+
},
158+
};
159+
await invokeHandler(event);
160+
161+
// THEN
162+
expect(mockECRClient.listImages).toHaveBeenCalledTimes(2);
163+
expect(mockECRClient.listImages).toHaveBeenCalledWith({ repositoryName: 'MyRepo' });
164+
expect(mockECRClient.batchDeleteImage).toHaveBeenCalledTimes(2);
165+
expect(mockECRClient.batchDeleteImage).toHaveBeenNthCalledWith(1, {
166+
repositoryName: 'MyRepo',
167+
imageIds: [
168+
{
169+
imageTag: 'tag1',
170+
imageDigest: 'sha256-1',
171+
},
172+
{
173+
imageTag: 'tag2',
174+
imageDigest: 'sha256-2',
175+
},
176+
],
177+
});
178+
expect(mockECRClient.batchDeleteImage).toHaveBeenNthCalledWith(2, {
179+
repositoryName: 'MyRepo',
180+
imageIds: [
181+
{
182+
imageTag: 'tag3',
183+
imageDigest: 'sha256-3',
184+
},
185+
{
186+
imageTag: 'tag4',
187+
imageDigest: 'sha256-4',
188+
},
189+
],
190+
});
191+
});
192+
193+
194+
// helper function to get around TypeScript expecting a complete event object,
195+
// even though our tests only need some of the fields
196+
async function invokeHandler(event: Partial<AWSLambda.CloudFormationCustomResourceEvent>) {
197+
return handler(event as AWSLambda.CloudFormationCustomResourceEvent);
198+
}

0 commit comments

Comments
 (0)