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(ecr): add option to auto delete images upon ECR repository removal #24572

Merged
merged 21 commits into from
Mar 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9a08f46
fix(aws-lambda-event-sources): clusterArn cannot be used as id of con…
randyridgley Nov 12, 2021
fbb90f6
fix(aws-lambda-event-sources): introduce eventMappingId similiar to k…
randyridgley Nov 12, 2021
f02e018
Merge branch 'master' into master
randyridgley Nov 15, 2021
6d72ce3
Merge branch 'master' into master
mergify[bot] Nov 15, 2021
6b64f17
Merge branch 'aws:master' into master
randyridgley Nov 17, 2021
380bc9d
Merge branch 'aws:master' into master
randyridgley Nov 29, 2021
715015a
Merge branch 'aws:master' into master
randyridgley Feb 8, 2022
e40d3cd
Merge branch 'aws:master' into master
randyridgley Mar 8, 2022
f4907dc
Merge branch 'aws:main' into master
randyridgley Mar 9, 2023
6de1f9e
feat(ecr): add option to auto delete images upon ECR repository removal
randyridgley Mar 10, 2023
575ddc5
feat(ecr): add option to auto delete images upon ECR repository removal
randyridgley Mar 10, 2023
87f4716
Merge branch 'aws:main' into master
randyridgley Mar 10, 2023
0c3a749
Merge branch 'main' into master
randyridgley Mar 13, 2023
0c33d61
Merge branch 'main' into master
randyridgley Mar 14, 2023
2c570f8
Merge branch 'main' into master
randyridgley Mar 20, 2023
d74452c
Merge branch 'main' into master
randyridgley Mar 20, 2023
b1a676a
Merge branch 'main' into master
randyridgley Mar 21, 2023
16e53a1
feat(ecr): add option to auto delete images upon ECR repository remov…
randyridgley Mar 21, 2023
65f925c
Merge branch 'main' into master
randyridgley Mar 21, 2023
6dfd514
Update packages/@aws-cdk/aws-ecr/lib/repository.ts
randyridgley Mar 21, 2023
e6ed559
Merge branch 'main' into master
mergify[bot] Mar 21, 2023
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
18 changes: 18 additions & 0 deletions packages/@aws-cdk/aws-ecr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,21 @@ declare const repository: ecr.Repository;
repository.addLifecycleRule({ tagPrefixList: ['prod'], maxImageCount: 9999 });
repository.addLifecycleRule({ maxImageAge: Duration.days(30) });
```

### Repository deletion

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

To override this and force all images to get deleted during repository deletion,
enable the`autoDeleteImages` option.

```ts
const repository = new Repository(this, 'MyTempRepo', {
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteImages: true,
});
```
94 changes: 94 additions & 0 deletions packages/@aws-cdk/aws-ecr/lib/auto-delete-images-handler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { ECR } from 'aws-sdk';

const AUTO_DELETE_IMAGES_TAG = 'aws-cdk:auto-delete-images';

const ecr = new ECR();

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
switch (event.RequestType) {
case 'Create':
randyridgley marked this conversation as resolved.
Show resolved Hide resolved
break;
case 'Update':
return onUpdate(event);
case 'Delete':
return onDelete(event.ResourceProperties?.RepositoryName);
}
}

async function onUpdate(event: AWSLambda.CloudFormationCustomResourceEvent) {
const updateEvent = event as AWSLambda.CloudFormationCustomResourceUpdateEvent;
const oldRepositoryName = updateEvent.OldResourceProperties?.RepositoryName;
const newRepositoryName = updateEvent.ResourceProperties?.RepositoryName;
const repositoryNameHasChanged = (newRepositoryName && oldRepositoryName)
&& (newRepositoryName !== oldRepositoryName);

/* If the name of the repository has changed, CloudFormation will try to delete the repository
and create a new one with the new name. So we have to delete the images in the
repository so that this operation does not fail. */
if (repositoryNameHasChanged) {
return onDelete(oldRepositoryName);
}
}

/**
* Recursively delete all images in the repository
*
* @param ECR.ListImagesRequest the repositoryName & nextToken if presented
*/
async function emptyRepository(params: ECR.ListImagesRequest) {
const listedImages = await ecr.listImages(params).promise();

const imageIds = listedImages?.imageIds ?? [];
const nextToken = listedImages.nextToken ?? null;
if (imageIds.length === 0) {
return;
}

await ecr.batchDeleteImage({
repositoryName: params.repositoryName,
imageIds,
}).promise();

if (nextToken) {
await emptyRepository({
...params,
nextToken,
});
}
}

async function onDelete(repositoryName: string) {
if (!repositoryName) {
throw new Error('No RepositoryName was provided.');
}

const response = await ecr.describeRepositories({ repositoryNames: [repositoryName] }).promise();
const repository = response.repositories?.find(repo => repo.repositoryName === repositoryName);

if (!await isRepositoryTaggedForDeletion(repository?.repositoryArn!)) {
process.stdout.write(`Repository does not have '${AUTO_DELETE_IMAGES_TAG}' tag, skipping cleaning.\n`);
return;
}
try {
await emptyRepository({ repositoryName });
} catch (e) {
if (e.name !== 'RepositoryNotFoundException') {
throw e;
}
// Repository doesn't exist. Ignoring
}
}

/**
* The repository will only be tagged for deletion if it's being deleted in the same
* deployment as this Custom Resource.
*
* If the Custom Resource is ever deleted before the repository, it must be because
* `autoDeleteImages` has been switched to false, in which case the tag would have
* been removed before we get to this Delete event.
*/
async function isRepositoryTaggedForDeletion(repositoryArn: string) {
const response = await ecr.listTagsForResource({ resourceArn: repositoryArn }).promise();
return response.tags?.some(tag => tag.Key === AUTO_DELETE_IMAGES_TAG && tag.Value === 'true');
}
76 changes: 75 additions & 1 deletion packages/@aws-cdk/aws-ecr/lib/repository.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
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 { ArnFormat, IResource, Lazy, RemovalPolicy, Resource, Stack, Tags, Token, TokenComparison } from '@aws-cdk/core';
import {
ArnFormat,
IResource,
Lazy,
RemovalPolicy,
Resource,
Stack,
Tags,
Token,
TokenComparison,
CustomResource,
CustomResourceProvider,
CustomResourceProviderRuntime,
} from '@aws-cdk/core';
import { IConstruct, Construct } from 'constructs';
import { CfnRepository } from './ecr.generated';
import { LifecycleRule, TagStatus } from './lifecycle';

const AUTO_DELETE_IMAGES_RESOURCE_TYPE = 'Custom::ECRAutoDeleteImages';
const AUTO_DELETE_IMAGES_TAG = 'aws-cdk:auto-delete-images';

/**
* Represents an ECR repository.
*/
Expand Down Expand Up @@ -479,6 +496,16 @@ export interface RepositoryProps {
* @default TagMutability.MUTABLE
*/
readonly imageTagMutability?: TagMutability;

/**
* Whether all images should be automatically deleted when the repository is
* removed from the stack or when the stack is deleted.
*
* Requires the `removalPolicy` to be set to `RemovalPolicy.DESTROY`.
*
* @default false
*/
readonly autoDeleteImages?: boolean;
}

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

constructor(scope: Construct, id: string, props: RepositoryProps = {}) {
super(scope, id, {
Expand All @@ -606,6 +634,14 @@ export class Repository extends RepositoryBase {
imageTagMutability: props.imageTagMutability || undefined,
encryptionConfiguration: this.parseEncryption(props),
});
this._resource = resource;

if (props.autoDeleteImages) {
if (props.removalPolicy !== RemovalPolicy.DESTROY) {
throw new Error('Cannot use \'autoDeleteImages\' property on a repository without setting removal policy to \'DESTROY\'.');
}
this.enableAutoDeleteImages();
}

resource.applyRemovalPolicy(props.removalPolicy);

Expand Down Expand Up @@ -741,6 +777,44 @@ export class Repository extends RepositoryBase {

throw new Error(`Unexpected 'encryptionType': ${encryptionType}`);
}

private enableAutoDeleteImages() {
// Use a iam policy to allow the custom resource to list & delete
// images in the repository and the ability to get all repositories to find the arn needed on delete.
const provider = CustomResourceProvider.getOrCreateProvider(this, AUTO_DELETE_IMAGES_RESOURCE_TYPE, {
codeDirectory: path.join(__dirname, 'auto-delete-images-handler'),
runtime: CustomResourceProviderRuntime.NODEJS_14_X,
description: `Lambda function for auto-deleting images in ${this.repositoryName} repository.`,
policyStatements: [
{
Effect: 'Allow',
Action: [
'ecr:BatchDeleteImage',
'ecr:DescribeRepositories',
'ecr:ListImages',
'ecr:ListTagsForResource',
],
Resource: [this._resource.attrArn],
},
],
});

const customResource = new CustomResource(this, 'AutoDeleteImagesCustomResource', {
resourceType: AUTO_DELETE_IMAGES_RESOURCE_TYPE,
serviceToken: provider.serviceToken,
properties: {
RepositoryName: Lazy.any({ produce: () => this.repositoryName }),
},
});
customResource.node.addDependency(this);

// We also tag the repository to record the fact that we want it autodeleted.
// The custom resource will check this tag before actually doing the delete.
// Because tagging and untagging will ALWAYS happen before the CR is deleted,
// we can set `autoDeleteImages: false` without the removal of the CR emptying
// the repository as a side effect.
Tags.of(this._resource).add(AUTO_DELETE_IMAGES_TAG, 'true');
}
}

function validateAnyRuleLast(rules: LifecycleRule[]) {
Expand Down
Loading