From aa075cd07a892e6c1d5243d5526e2c8658b98621 Mon Sep 17 00:00:00 2001 From: "k.goto" <24818752+go-to-k@users.noreply.github.com> Date: Thu, 21 Dec 2023 04:11:50 +0900 Subject: [PATCH] feat(ecr): tag pattern list for lifecycle policy (#28432) 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* --- .../aws-ecr-integ-stack.assets.json | 4 +- .../aws-ecr-integ-stack.template.json | 2 +- .../integ.basic.js.snapshot/manifest.json | 2 +- .../test/integ.basic.js.snapshot/tree.json | 58 ++++++------ .../test/aws-ecr/test/integ.basic.ts | 2 + packages/aws-cdk-lib/aws-ecr/README.md | 8 ++ packages/aws-cdk-lib/aws-ecr/lib/lifecycle.ts | 20 +++- .../aws-cdk-lib/aws-ecr/lib/repository.ts | 25 ++++- .../aws-ecr/test/repository.test.ts | 91 ++++++++++++++++++- packages/aws-cdk-lib/awslint.json | 3 +- 10 files changed, 174 insertions(+), 41 deletions(-) diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/aws-ecr-integ-stack.assets.json b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/aws-ecr-integ-stack.assets.json index b4291f6796366..b1ebebaa8af67 100644 --- a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/aws-ecr-integ-stack.assets.json +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/aws-ecr-integ-stack.assets.json @@ -1,7 +1,7 @@ { "version": "35.0.0", "files": { - "5496c737b6cd337f0e41f8e73a155aca194554cafe08f2937b95abcb5807a636": { + "830646461dc1fed84d30409177199864dfe0b167864b2fcdca22f4b9aad8a063": { "source": { "path": "aws-ecr-integ-stack.template.json", "packaging": "file" @@ -9,7 +9,7 @@ "destinations": { "current_account-current_region": { "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", - "objectKey": "5496c737b6cd337f0e41f8e73a155aca194554cafe08f2937b95abcb5807a636.json", + "objectKey": "830646461dc1fed84d30409177199864dfe0b167864b2fcdca22f4b9aad8a063.json", "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" } } diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/aws-ecr-integ-stack.template.json b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/aws-ecr-integ-stack.template.json index a9eed9841f45e..ed4ac96c9385e 100644 --- a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/aws-ecr-integ-stack.template.json +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/aws-ecr-integ-stack.template.json @@ -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": [ diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/manifest.json b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/manifest.json index e011369a944fa..f53c26891f1ed 100644 --- a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/manifest.json +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/manifest.json @@ -18,7 +18,7 @@ "validateOnSynth": false, "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", - "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/5496c737b6cd337f0e41f8e73a155aca194554cafe08f2937b95abcb5807a636.json", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/830646461dc1fed84d30409177199864dfe0b167864b2fcdca22f4b9aad8a063.json", "requiresBootstrapStackVersion": 6, "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", "additionalDependencies": [ diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/tree.json b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/tree.json index 35e356fa8be0c..53559b0722b55 100644 --- a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/tree.json +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.js.snapshot/tree.json @@ -19,7 +19,7 @@ "aws:cdk:cloudformation:type": "AWS::ECR::Repository", "aws:cdk:cloudformation:props": { "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": [ @@ -36,14 +36,14 @@ } }, "constructInfo": { - "fqn": "aws-cdk-lib.aws_ecr.CfnRepository", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } } }, "constructInfo": { - "fqn": "aws-cdk-lib.aws_ecr.Repository", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } }, "MyUser": { @@ -58,8 +58,8 @@ "aws:cdk:cloudformation:props": {} }, "constructInfo": { - "fqn": "aws-cdk-lib.aws_iam.CfnUser", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } }, "DefaultPolicy": { @@ -111,20 +111,20 @@ } }, "constructInfo": { - "fqn": "aws-cdk-lib.aws_iam.CfnPolicy", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } } }, "constructInfo": { - "fqn": "aws-cdk-lib.aws_iam.Policy", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } } }, "constructInfo": { - "fqn": "aws-cdk-lib.aws_iam.User", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } }, "RepoWithEmptyOnDelete": { @@ -155,30 +155,30 @@ "id": "RepositoryURI", "path": "aws-ecr-integ-stack/RepositoryURI", "constructInfo": { - "fqn": "aws-cdk-lib.CfnOutput", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } }, "BootstrapVersion": { "id": "BootstrapVersion", "path": "aws-ecr-integ-stack/BootstrapVersion", "constructInfo": { - "fqn": "aws-cdk-lib.CfnParameter", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } }, "CheckBootstrapVersion": { "id": "CheckBootstrapVersion", "path": "aws-ecr-integ-stack/CheckBootstrapVersion", "constructInfo": { - "fqn": "aws-cdk-lib.CfnRule", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } } }, "constructInfo": { - "fqn": "aws-cdk-lib.Stack", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } }, "cdk-ecr-integ-test-basic": { @@ -205,22 +205,22 @@ "id": "BootstrapVersion", "path": "cdk-ecr-integ-test-basic/DefaultTest/DeployAssert/BootstrapVersion", "constructInfo": { - "fqn": "aws-cdk-lib.CfnParameter", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } }, "CheckBootstrapVersion": { "id": "CheckBootstrapVersion", "path": "cdk-ecr-integ-test-basic/DefaultTest/DeployAssert/CheckBootstrapVersion", "constructInfo": { - "fqn": "aws-cdk-lib.CfnRule", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } } }, "constructInfo": { - "fqn": "aws-cdk-lib.Stack", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } } }, @@ -245,8 +245,8 @@ } }, "constructInfo": { - "fqn": "aws-cdk-lib.App", - "version": "0.0.0" + "fqn": "constructs.Construct", + "version": "10.3.0" } } } \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.ts b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.ts index eb0c05a04e971..199c1d7f152ae 100644 --- a/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.ts +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-ecr/test/integ.basic.ts @@ -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()], diff --git a/packages/aws-cdk-lib/aws-ecr/README.md b/packages/aws-cdk-lib/aws-ecr/README.md index 7ad75d94215c4..bba3db8e0ac27 100644 --- a/packages/aws-cdk-lib/aws-ecr/README.md +++ b/packages/aws-cdk-lib/aws-ecr/README.md @@ -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 diff --git a/packages/aws-cdk-lib/aws-ecr/lib/lifecycle.ts b/packages/aws-cdk-lib/aws-ecr/lib/lifecycle.ts index 8349745be5599..aff39f9909bd8 100644 --- a/packages/aws-cdk-lib/aws-ecr/lib/lifecycle.ts +++ b/packages/aws-cdk-lib/aws-ecr/lib/lifecycle.ts @@ -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 * diff --git a/packages/aws-cdk-lib/aws-ecr/lib/repository.ts b/packages/aws-cdk-lib/aws-ecr/lib/repository.ts index 666702a9c462a..720bf9002331c 100644 --- a/packages/aws-cdk-lib/aws-ecr/lib/repository.ts +++ b/packages/aws-cdk-lib/aws-ecr/lib/repository.ts @@ -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)}`); @@ -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, diff --git a/packages/aws-cdk-lib/aws-ecr/test/repository.test.ts b/packages/aws-cdk-lib/aws-ecr/test/repository.test.ts index 2bd65a3a79ae6..700a8e6ca8ea0 100644 --- a/packages/aws-cdk-lib/aws-ecr/test/repository.test.ts +++ b/packages/aws-cdk-lib/aws-ecr/test/repository.test.ts @@ -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'); @@ -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(); diff --git a/packages/aws-cdk-lib/awslint.json b/packages/aws-cdk-lib/awslint.json index 6d19ae862876e..9f44fe0c02597 100644 --- a/packages/aws-cdk-lib/awslint.json +++ b/packages/aws-cdk-lib/awslint.json @@ -660,6 +660,7 @@ "props-default-doc:aws-cdk-lib.aws_ecr.LifecycleRule.maxImageAge", "props-default-doc:aws-cdk-lib.aws_ecr.LifecycleRule.maxImageCount", "props-default-doc:aws-cdk-lib.aws_ecr.LifecycleRule.tagPrefixList", + "props-default-doc:aws-cdk-lib.aws_ecr.LifecycleRule.tagPatternList", "docs-public-apis:aws-cdk-lib.aws_ecr.RepositoryAttributes", "docs-public-apis:aws-cdk-lib.aws_ecr.RepositoryAttributes.repositoryArn", "docs-public-apis:aws-cdk-lib.aws_ecr.RepositoryAttributes.repositoryName", @@ -898,4 +899,4 @@ "from-method:aws-cdk-lib.aws_apigatewayv2.WebSocketIntegration", "from-method:aws-cdk-lib.aws_apigatewayv2.WebSocketRoute" ] -} +} \ No newline at end of file