Skip to content

Commit

Permalink
feat(ecr): add option to auto delete images upon ECR repository remov…
Browse files Browse the repository at this point in the history
…al (#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*
  • Loading branch information
randyridgley authored Mar 21, 2023
1 parent d4717cf commit 7de5b00
Show file tree
Hide file tree
Showing 15 changed files with 1,461 additions and 1 deletion.
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':
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

0 comments on commit 7de5b00

Please sign in to comment.