Skip to content

Commit

Permalink
feat(ecr): tag pattern list for lifecycle policy (aws#28432)
Browse files Browse the repository at this point in the history
This PR supports `tagPatternList` for the lifecycle policy.

According to the doc, the lifecycle policy has following evaluation rules:

> A lifecycle policy rule may specify either tagPatternList or tagPrefixList, but not both. 

> The tagPatternList or tagPrefixList parameters may only used if the tagStatus is tagged.

> There is a maximum limit of four wildcards (\*) per string. For example, ["\*test\*1\*2\*3", "test\*1\*2\*3\*"] is valid but ["test\*1\*2\*3\*4\*5\*6"] is invalid.

https://docs.aws.amazon.com/AmazonECR/latest/userguide/LifecyclePolicies.html#lp_tag_pattern_list

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
go-to-k authored and paulhcsun committed Jan 5, 2024
1 parent 2f230e1 commit aaf781e
Show file tree
Hide file tree
Showing 10 changed files with 174 additions and 41 deletions.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"Type": "AWS::ECR::Repository",
"Properties": {
"LifecyclePolicy": {
"LifecyclePolicyText": "{\"rules\":[{\"rulePriority\":1,\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":5},\"action\":{\"type\":\"expire\"}}]}"
"LifecyclePolicyText": "{\"rules\":[{\"rulePriority\":1,\"selection\":{\"tagStatus\":\"tagged\",\"tagPrefixList\":[\"abc\"],\"countType\":\"imageCountMoreThan\",\"countNumber\":3},\"action\":{\"type\":\"expire\"}},{\"rulePriority\":2,\"selection\":{\"tagStatus\":\"tagged\",\"tagPatternList\":[\"abc*\"],\"countType\":\"imageCountMoreThan\",\"countNumber\":3},\"action\":{\"type\":\"expire\"}},{\"rulePriority\":3,\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":5},\"action\":{\"type\":\"expire\"}}]}"
},
"RepositoryPolicyText": {
"Statement": [
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const stack = new cdk.Stack(app, 'aws-ecr-integ-stack');

const repo = new ecr.Repository(stack, 'Repo');
repo.addLifecycleRule({ maxImageCount: 5 });
repo.addLifecycleRule({ tagPrefixList: ['abc'], maxImageCount: 3 });
repo.addLifecycleRule({ tagPatternList: ['abc*'], maxImageCount: 3 });
repo.addToResourcePolicy(new iam.PolicyStatement({
actions: ['ecr:GetDownloadUrlForLayer'],
principals: [new iam.AnyPrincipal()],
Expand Down
8 changes: 8 additions & 0 deletions packages/aws-cdk-lib/aws-ecr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ repository.addLifecycleRule({ tagPrefixList: ['prod'], maxImageCount: 9999 });
repository.addLifecycleRule({ maxImageAge: Duration.days(30) });
```

When using `tagPatternList`, an image is successfully matched if it matches
the wildcard filter.

```ts
declare const repository: ecr.Repository;
repository.addLifecycleRule({ tagPatternList: ['prod*'], maxImageCount: 9999 });
```

### Repository deletion

When a repository is removed from a stack (or the stack is deleted), the ECR
Expand Down
20 changes: 19 additions & 1 deletion packages/aws-cdk-lib/aws-ecr/lib/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,35 @@ export interface LifecycleRule {
* Only one rule is allowed to select untagged images, and it must
* have the highest rulePriority.
*
* @default TagStatus.Tagged if tagPrefixList is given, TagStatus.Any otherwise
* @default TagStatus.Tagged if tagPrefixList or tagPatternList is
* given, TagStatus.Any otherwise
*/
readonly tagStatus?: TagStatus;

/**
* Select images that have ALL the given prefixes in their tag.
*
* Both tagPrefixList and tagPatternList cannot be specified
* together in a rule.
*
* Only if tagStatus == TagStatus.Tagged
*/
readonly tagPrefixList?: string[];

/**
* Select images that have ALL the given patterns in their tag.
*
* There is a maximum limit of four wildcards (*) per string.
* For example, ["*test*1*2*3", "test*1*2*3*"] is valid but
* ["test*1*2*3*4*5*6"] is invalid.
*
* Both tagPrefixList and tagPatternList cannot be specified
* together in a rule.
*
* Only if tagStatus == TagStatus.Tagged
*/
readonly tagPatternList?: string[];

/**
* The maximum number of images to retain
*
Expand Down
25 changes: 20 additions & 5 deletions packages/aws-cdk-lib/aws-ecr/lib/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,14 +769,28 @@ export class Repository extends RepositoryBase {
public addLifecycleRule(rule: LifecycleRule) {
// Validate rule here so users get errors at the expected location
if (rule.tagStatus === undefined) {
rule = { ...rule, tagStatus: rule.tagPrefixList === undefined ? TagStatus.ANY : TagStatus.TAGGED };
rule = { ...rule, tagStatus: rule.tagPrefixList === undefined && rule.tagPatternList === undefined ? TagStatus.ANY : TagStatus.TAGGED };
}

if (rule.tagStatus === TagStatus.TAGGED && (rule.tagPrefixList === undefined || rule.tagPrefixList.length === 0)) {
throw new Error('TagStatus.Tagged requires the specification of a tagPrefixList');
if (rule.tagStatus === TagStatus.TAGGED
&& (rule.tagPrefixList === undefined || rule.tagPrefixList.length === 0)
&& (rule.tagPatternList === undefined || rule.tagPatternList.length === 0)
) {
throw new Error('TagStatus.Tagged requires the specification of a tagPrefixList or a tagPatternList');
}
if (rule.tagStatus !== TagStatus.TAGGED && rule.tagPrefixList !== undefined) {
throw new Error('tagPrefixList can only be specified when tagStatus is set to Tagged');
if (rule.tagStatus !== TagStatus.TAGGED && (rule.tagPrefixList !== undefined || rule.tagPatternList !== undefined)) {
throw new Error('tagPrefixList and tagPatternList can only be specified when tagStatus is set to Tagged');
}
if (rule.tagPrefixList !== undefined && rule.tagPatternList !== undefined) {
throw new Error('Both tagPrefixList and tagPatternList cannot be specified together in a rule');
}
if (rule.tagPatternList !== undefined) {
rule.tagPatternList.forEach((pattern) => {
const splitPatternLength = pattern.split('*').length;
if (splitPatternLength > 5) {
throw new Error(`A tag pattern cannot contain more than four wildcard characters (*), pattern: ${pattern}, counts: ${splitPatternLength - 1}`);
}
});
}
if ((rule.maxImageAge !== undefined) === (rule.maxImageCount !== undefined)) {
throw new Error(`Life cycle rule must contain exactly one of 'maxImageAge' and 'maxImageCount', got: ${JSON.stringify(rule)}`);
Expand Down Expand Up @@ -935,6 +949,7 @@ function renderLifecycleRule(rule: LifecycleRule) {
selection: {
tagStatus: rule.tagStatus || TagStatus.ANY,
tagPrefixList: rule.tagPrefixList,
tagPatternList: rule.tagPatternList,
countType: rule.maxImageAge !== undefined ? CountType.SINCE_IMAGE_PUSHED : CountType.IMAGE_COUNT_MORE_THAN,
countNumber: rule.maxImageAge?.toDays() ?? rule.maxImageCount,
countUnit: rule.maxImageAge !== undefined ? 'days' : undefined,
Expand Down
91 changes: 90 additions & 1 deletion packages/aws-cdk-lib/aws-ecr/test/repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('repository', () => {
});
});

test('tag-based lifecycle policy', () => {
test('tag-based lifecycle policy with tagPrefixList', () => {
// GIVEN
const stack = new cdk.Stack();
const repo = new ecr.Repository(stack, 'Repo');
Expand All @@ -66,6 +66,95 @@ describe('repository', () => {
});
});

test('tag-based lifecycle policy with tagPatternList', () => {
// GIVEN
const stack = new cdk.Stack();
const repo = new ecr.Repository(stack, 'Repo');

// WHEN
repo.addLifecycleRule({ tagPatternList: ['abc*'], maxImageCount: 1 });

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::ECR::Repository', {
LifecyclePolicy: {
// eslint-disable-next-line max-len
LifecyclePolicyText: '{"rules":[{"rulePriority":1,"selection":{"tagStatus":"tagged","tagPatternList":["abc*"],"countType":"imageCountMoreThan","countNumber":1},"action":{"type":"expire"}}]}',
},
});
});

test('both tagPrefixList and tagPatternList cannot be specified together in a rule', () => {
// GIVEN
const stack = new cdk.Stack();
const repo = new ecr.Repository(stack, 'Repo');

// THEN
expect(() => {
repo.addLifecycleRule({ tagPrefixList: ['abc'], tagPatternList: ['abc*'], maxImageCount: 1 });
}).toThrow(/Both tagPrefixList and tagPatternList cannot be specified together in a rule/);
});

test('tagPrefixList can only be specified when tagStatus is set to Tagged', () => {
// GIVEN
const stack = new cdk.Stack();
const repo = new ecr.Repository(stack, 'Repo');

// THEN
expect(() => {
repo.addLifecycleRule({ tagStatus: ecr.TagStatus.ANY, tagPrefixList: ['abc'], maxImageCount: 1 });
}).toThrow(/tagPrefixList and tagPatternList can only be specified when tagStatus is set to Tagged/);
});

test('tagPatternList can only be specified when tagStatus is set to Tagged', () => {
// GIVEN
const stack = new cdk.Stack();
const repo = new ecr.Repository(stack, 'Repo');

// THEN
expect(() => {
repo.addLifecycleRule({ tagStatus: ecr.TagStatus.ANY, tagPatternList: ['abc*'], maxImageCount: 1 });
}).toThrow(/tagPrefixList and tagPatternList can only be specified when tagStatus is set to Tagged/);
});

test('TagStatus.Tagged requires the specification of a tagPrefixList or a tagPatternList', () => {
// GIVEN
const stack = new cdk.Stack();
const repo = new ecr.Repository(stack, 'Repo');

// THEN
expect(() => {
repo.addLifecycleRule({ tagStatus: ecr.TagStatus.TAGGED, maxImageCount: 1 });
}).toThrow(/TagStatus.Tagged requires the specification of a tagPrefixList or a tagPatternList/);
});

test('A tag pattern can contain four wildcard characters', () => {
// GIVEN
const stack = new cdk.Stack();
const repo = new ecr.Repository(stack, 'Repo');

// WHEN
repo.addLifecycleRule({ tagPatternList: ['abc*d*e*f*'], maxImageCount: 1 });

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::ECR::Repository', {
LifecyclePolicy: {
// eslint-disable-next-line max-len
LifecyclePolicyText: '{"rules":[{"rulePriority":1,"selection":{"tagStatus":"tagged","tagPatternList":["abc*d*e*f*"],"countType":"imageCountMoreThan","countNumber":1},"action":{"type":"expire"}}]}',
},
});
});

test('A tag pattern cannot contain more than four wildcard characters', () => {
// GIVEN
const stack = new cdk.Stack();
const repo = new ecr.Repository(stack, 'Repo');

// THEN
expect(() => {
repo.addLifecycleRule({ tagPatternList: ['abc*d*e*f*g*h'], maxImageCount: 1 });
}).toThrow(/A tag pattern cannot contain more than four wildcard characters \(\*\), pattern: abc\*d\*e\*f\*g\*h, counts: 5/);
});

test('image tag mutability can be set', () => {
// GIVEN
const stack = new cdk.Stack();
Expand Down
Loading

0 comments on commit aaf781e

Please sign in to comment.