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): tag pattern list for lifecycle policy #28432

Merged
merged 11 commits into from
Dec 20, 2023

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 splittedPatternLength = pattern.split('*').length;
if (splittedPatternLength > 5) {
throw new Error(`A tag pattern cannot contain more than four wildcard characters (*), pattern: ${pattern}, counts: ${splittedPatternLength - 1}`);
kaizencc marked this conversation as resolved.
Show resolved Hide resolved
}
});
}
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
Loading