Skip to content

Commit 7de5b00

Browse files
authored
feat(ecr): add option to auto delete images upon ECR repository removal (#24572)
This request fixes the ECR Repository resource to allow setting a flag on the resource to auto delete the images in the repository. This is similar to the way S3 handles the autoDeleteObjects attribute. This code base starts from a stalled PR [#15932](#15932). This also takes into account the functionality added into S3 to create tag to not delete images if the flag is flipped from true to false. Closes [#12618](#12618) References closed and not merged PR [#15932](#15932) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent d4717cf commit 7de5b00

15 files changed

+1461
-1
lines changed

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

+18
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,21 @@ declare const repository: ecr.Repository;
118118
repository.addLifecycleRule({ tagPrefixList: ['prod'], maxImageCount: 9999 });
119119
repository.addLifecycleRule({ maxImageAge: Duration.days(30) });
120120
```
121+
122+
### Repository deletion
123+
124+
When a repository is removed from a stack (or the stack is deleted), the ECR
125+
repository will be removed according to its removal policy (which by default will
126+
simply orphan the repository and leave it in your AWS account). If the removal
127+
policy is set to `RemovalPolicy.DESTROY`, the repository will be deleted as long
128+
as it does not contain any images.
129+
130+
To override this and force all images to get deleted during repository deletion,
131+
enable the`autoDeleteImages` option.
132+
133+
```ts
134+
const repository = new Repository(this, 'MyTempRepo', {
135+
removalPolicy: RemovalPolicy.DESTROY,
136+
autoDeleteImages: true,
137+
});
138+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// eslint-disable-next-line import/no-extraneous-dependencies
2+
import { ECR } from 'aws-sdk';
3+
4+
const AUTO_DELETE_IMAGES_TAG = 'aws-cdk:auto-delete-images';
5+
6+
const ecr = new ECR();
7+
8+
export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
9+
switch (event.RequestType) {
10+
case 'Create':
11+
break;
12+
case 'Update':
13+
return onUpdate(event);
14+
case 'Delete':
15+
return onDelete(event.ResourceProperties?.RepositoryName);
16+
}
17+
}
18+
19+
async function onUpdate(event: AWSLambda.CloudFormationCustomResourceEvent) {
20+
const updateEvent = event as AWSLambda.CloudFormationCustomResourceUpdateEvent;
21+
const oldRepositoryName = updateEvent.OldResourceProperties?.RepositoryName;
22+
const newRepositoryName = updateEvent.ResourceProperties?.RepositoryName;
23+
const repositoryNameHasChanged = (newRepositoryName && oldRepositoryName)
24+
&& (newRepositoryName !== oldRepositoryName);
25+
26+
/* If the name of the repository has changed, CloudFormation will try to delete the repository
27+
and create a new one with the new name. So we have to delete the images in the
28+
repository so that this operation does not fail. */
29+
if (repositoryNameHasChanged) {
30+
return onDelete(oldRepositoryName);
31+
}
32+
}
33+
34+
/**
35+
* Recursively delete all images in the repository
36+
*
37+
* @param ECR.ListImagesRequest the repositoryName & nextToken if presented
38+
*/
39+
async function emptyRepository(params: ECR.ListImagesRequest) {
40+
const listedImages = await ecr.listImages(params).promise();
41+
42+
const imageIds = listedImages?.imageIds ?? [];
43+
const nextToken = listedImages.nextToken ?? null;
44+
if (imageIds.length === 0) {
45+
return;
46+
}
47+
48+
await ecr.batchDeleteImage({
49+
repositoryName: params.repositoryName,
50+
imageIds,
51+
}).promise();
52+
53+
if (nextToken) {
54+
await emptyRepository({
55+
...params,
56+
nextToken,
57+
});
58+
}
59+
}
60+
61+
async function onDelete(repositoryName: string) {
62+
if (!repositoryName) {
63+
throw new Error('No RepositoryName was provided.');
64+
}
65+
66+
const response = await ecr.describeRepositories({ repositoryNames: [repositoryName] }).promise();
67+
const repository = response.repositories?.find(repo => repo.repositoryName === repositoryName);
68+
69+
if (!await isRepositoryTaggedForDeletion(repository?.repositoryArn!)) {
70+
process.stdout.write(`Repository does not have '${AUTO_DELETE_IMAGES_TAG}' tag, skipping cleaning.\n`);
71+
return;
72+
}
73+
try {
74+
await emptyRepository({ repositoryName });
75+
} catch (e) {
76+
if (e.name !== 'RepositoryNotFoundException') {
77+
throw e;
78+
}
79+
// Repository doesn't exist. Ignoring
80+
}
81+
}
82+
83+
/**
84+
* The repository will only be tagged for deletion if it's being deleted in the same
85+
* deployment as this Custom Resource.
86+
*
87+
* If the Custom Resource is ever deleted before the repository, it must be because
88+
* `autoDeleteImages` has been switched to false, in which case the tag would have
89+
* been removed before we get to this Delete event.
90+
*/
91+
async function isRepositoryTaggedForDeletion(repositoryArn: string) {
92+
const response = await ecr.listTagsForResource({ resourceArn: repositoryArn }).promise();
93+
return response.tags?.some(tag => tag.Key === AUTO_DELETE_IMAGES_TAG && tag.Value === 'true');
94+
}

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

+75-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
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';
45
import * as kms from '@aws-cdk/aws-kms';
5-
import { ArnFormat, IResource, Lazy, RemovalPolicy, Resource, Stack, Tags, Token, TokenComparison } from '@aws-cdk/core';
6+
import {
7+
ArnFormat,
8+
IResource,
9+
Lazy,
10+
RemovalPolicy,
11+
Resource,
12+
Stack,
13+
Tags,
14+
Token,
15+
TokenComparison,
16+
CustomResource,
17+
CustomResourceProvider,
18+
CustomResourceProviderRuntime,
19+
} from '@aws-cdk/core';
620
import { IConstruct, Construct } from 'constructs';
721
import { CfnRepository } from './ecr.generated';
822
import { LifecycleRule, TagStatus } from './lifecycle';
923

24+
const AUTO_DELETE_IMAGES_RESOURCE_TYPE = 'Custom::ECRAutoDeleteImages';
25+
const AUTO_DELETE_IMAGES_TAG = 'aws-cdk:auto-delete-images';
26+
1027
/**
1128
* Represents an ECR repository.
1229
*/
@@ -479,6 +496,16 @@ export interface RepositoryProps {
479496
* @default TagMutability.MUTABLE
480497
*/
481498
readonly imageTagMutability?: TagMutability;
499+
500+
/**
501+
* Whether all images should be automatically deleted when the repository is
502+
* removed from the stack or when the stack is deleted.
503+
*
504+
* Requires the `removalPolicy` to be set to `RemovalPolicy.DESTROY`.
505+
*
506+
* @default false
507+
*/
508+
readonly autoDeleteImages?: boolean;
482509
}
483510

484511
export interface RepositoryAttributes {
@@ -589,6 +616,7 @@ export class Repository extends RepositoryBase {
589616
private readonly lifecycleRules = new Array<LifecycleRule>();
590617
private readonly registryId?: string;
591618
private policyDocument?: iam.PolicyDocument;
619+
private readonly _resource: CfnRepository;
592620

593621
constructor(scope: Construct, id: string, props: RepositoryProps = {}) {
594622
super(scope, id, {
@@ -606,6 +634,14 @@ export class Repository extends RepositoryBase {
606634
imageTagMutability: props.imageTagMutability || undefined,
607635
encryptionConfiguration: this.parseEncryption(props),
608636
});
637+
this._resource = resource;
638+
639+
if (props.autoDeleteImages) {
640+
if (props.removalPolicy !== RemovalPolicy.DESTROY) {
641+
throw new Error('Cannot use \'autoDeleteImages\' property on a repository without setting removal policy to \'DESTROY\'.');
642+
}
643+
this.enableAutoDeleteImages();
644+
}
609645

610646
resource.applyRemovalPolicy(props.removalPolicy);
611647

@@ -741,6 +777,44 @@ export class Repository extends RepositoryBase {
741777

742778
throw new Error(`Unexpected 'encryptionType': ${encryptionType}`);
743779
}
780+
781+
private enableAutoDeleteImages() {
782+
// Use a iam policy to allow the custom resource to list & delete
783+
// images in the repository and the ability to get all repositories to find the arn needed on delete.
784+
const provider = CustomResourceProvider.getOrCreateProvider(this, AUTO_DELETE_IMAGES_RESOURCE_TYPE, {
785+
codeDirectory: path.join(__dirname, 'auto-delete-images-handler'),
786+
runtime: CustomResourceProviderRuntime.NODEJS_14_X,
787+
description: `Lambda function for auto-deleting images in ${this.repositoryName} repository.`,
788+
policyStatements: [
789+
{
790+
Effect: 'Allow',
791+
Action: [
792+
'ecr:BatchDeleteImage',
793+
'ecr:DescribeRepositories',
794+
'ecr:ListImages',
795+
'ecr:ListTagsForResource',
796+
],
797+
Resource: [this._resource.attrArn],
798+
},
799+
],
800+
});
801+
802+
const customResource = new CustomResource(this, 'AutoDeleteImagesCustomResource', {
803+
resourceType: AUTO_DELETE_IMAGES_RESOURCE_TYPE,
804+
serviceToken: provider.serviceToken,
805+
properties: {
806+
RepositoryName: Lazy.any({ produce: () => this.repositoryName }),
807+
},
808+
});
809+
customResource.node.addDependency(this);
810+
811+
// We also tag the repository to record the fact that we want it autodeleted.
812+
// The custom resource will check this tag before actually doing the delete.
813+
// Because tagging and untagging will ALWAYS happen before the CR is deleted,
814+
// we can set `autoDeleteImages: false` without the removal of the CR emptying
815+
// the repository as a side effect.
816+
Tags.of(this._resource).add(AUTO_DELETE_IMAGES_TAG, 'true');
817+
}
744818
}
745819

746820
function validateAnyRuleLast(rules: LifecycleRule[]) {

0 commit comments

Comments
 (0)