diff --git a/packages/@aws-cdk/aws-ecr/lib/repository.ts b/packages/@aws-cdk/aws-ecr/lib/repository.ts index 85071e61976e9..8d44e27ec7526 100644 --- a/packages/@aws-cdk/aws-ecr/lib/repository.ts +++ b/packages/@aws-cdk/aws-ecr/lib/repository.ts @@ -80,6 +80,21 @@ export interface IRepository extends IResource { * @param options Options for adding the rule */ onCloudTrailImagePushed(id: string, options?: OnCloudTrailImagePushedOptions): events.Rule; + + /** + * Defines an AWS CloudWatch event rule that can trigger a target when the image scan is completed + * + * + * @param id The id of the rule + * @param options Options for adding the rule + */ + onImageScanCompleted(id: string, options?: OnImageScanCompletedOptions): events.Rule; + + /** + * Defines a CloudWatch event rule which triggers for repository events. Use + * `rule.addEventPattern(pattern)` to specify a filter. + */ + onEvent(id: string, options?: events.OnEventOptions): events.Rule; } /** @@ -170,7 +185,41 @@ export abstract class RepositoryBase extends Resource implements IRepository { }); return rule; } + /** + * Defines an AWS CloudWatch event rule that can trigger a target when an image scan is completed + * + * + * @param id The id of the rule + * @param options Options for adding the rule + */ + public onImageScanCompleted(id: string, options: OnImageScanCompletedOptions = {}): events.Rule { + const rule = new events.Rule(this, id, options); + rule.addTarget(options.target); + rule.addEventPattern({ + source: ['aws.ecr'], + detailType: ['ECR Image Scan'], + detail: { + 'repository-name': [this.repositoryName], + 'scan-status': ['COMPLETE'], + 'image-tags': options.imageTags ? options.imageTags : undefined + } + }); + return rule; + } + /** + * Defines a CloudWatch event rule which triggers for repository events. Use + * `rule.addEventPattern(pattern)` to specify a filter. + */ + public onEvent(id: string, options: events.OnEventOptions = {}) { + const rule = new events.Rule(this, id, options); + rule.addEventPattern({ + source: ['aws.ecr'], + resources: [this.repositoryArn] + }); + rule.addTarget(options.target); + return rule; + } /** * Grant the given principal identity permissions to perform the actions on this repository */ @@ -225,6 +274,19 @@ export interface OnCloudTrailImagePushedOptions extends events.OnEventOptions { readonly imageTag?: string; } +/** + * Options for the OnImageScanCompleted method + */ +export interface OnImageScanCompletedOptions extends events.OnEventOptions { + /** + * Only watch changes to the image tags spedified. + * Leave it undefined to watch the full repository. + * + * @default - Watch the changes to the repository with all image tags + */ + readonly imageTags?: string[]; +} + export interface RepositoryProps { /** * Name for this repository diff --git a/packages/@aws-cdk/aws-ecr/test/integ.imagescan.expected.json b/packages/@aws-cdk/aws-ecr/test/integ.imagescan.expected.json new file mode 100644 index 0000000000000..7a1d2e693db11 --- /dev/null +++ b/packages/@aws-cdk/aws-ecr/test/integ.imagescan.expected.json @@ -0,0 +1,85 @@ +{ + "Resources": { + "Repo02AC86CF": { + "Type": "AWS::ECR::Repository", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "RepoImageScanComplete7BC71935": { + "Type": "AWS::Events::Rule", + "Properties": { + "EventPattern": { + "source": [ + "aws.ecr" + ], + "detail-type": [ + "ECR Image Scan" + ], + "detail": { + "repository-name": [ + { + "Ref": "Repo02AC86CF" + } + ], + "scan-status": [ + "COMPLETE" + ] + } + }, + "State": "ENABLED" + } + } + }, + "Outputs": { + "RepositoryURI": { + "Value": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "Repo02AC86CF", + "Arn" + ] + } + ] + } + ] + }, + ".dkr.ecr.", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "Repo02AC86CF", + "Arn" + ] + } + ] + } + ] + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "Repo02AC86CF" + } + ] + ] + } + } + } +} diff --git a/packages/@aws-cdk/aws-ecr/test/integ.imagescan.ts b/packages/@aws-cdk/aws-ecr/test/integ.imagescan.ts new file mode 100644 index 0000000000000..aca1d6fd64cc5 --- /dev/null +++ b/packages/@aws-cdk/aws-ecr/test/integ.imagescan.ts @@ -0,0 +1,15 @@ +import cdk = require('@aws-cdk/core'); +import ecr = require('../lib'); + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-ecr-integ-stack'); + +const repo = new ecr.Repository(stack, 'Repo'); +repo.onImageScanCompleted('ImageScanComplete', { +}); + +new cdk.CfnOutput(stack, 'RepositoryURI', { + value: repo.repositoryUri +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-ecr/test/test.repository.ts b/packages/@aws-cdk/aws-ecr/test/test.repository.ts index d3fc49569fc07..5904c08b729d8 100644 --- a/packages/@aws-cdk/aws-ecr/test/test.repository.ts +++ b/packages/@aws-cdk/aws-ecr/test/test.repository.ts @@ -160,16 +160,18 @@ export = { const uri = repo.repositoryUri; // THEN - const arnSplit = { 'Fn::Split': [ ':', { 'Fn::GetAtt': [ 'Repo02AC86CF', 'Arn' ] } ] }; - test.deepEqual(stack.resolve(uri), { 'Fn::Join': [ '', [ - { 'Fn::Select': [ 4, arnSplit ] }, - '.dkr.ecr.', - { 'Fn::Select': [ 3, arnSplit ] }, - '.', - { Ref: 'AWS::URLSuffix' }, - '/', - { Ref: 'Repo02AC86CF' } - ]]}); + const arnSplit = { 'Fn::Split': [':', { 'Fn::GetAtt': ['Repo02AC86CF', 'Arn'] }] }; + test.deepEqual(stack.resolve(uri), { + 'Fn::Join': ['', [ + { 'Fn::Select': [4, arnSplit] }, + '.dkr.ecr.', + { 'Fn::Select': [3, arnSplit] }, + '.', + { Ref: 'AWS::URLSuffix' }, + '/', + { Ref: 'Repo02AC86CF' } + ]] + }); test.done(); }, @@ -210,8 +212,8 @@ export = { }); // THEN - test.deepEqual(stack.resolve(repo.repositoryArn), { 'Fn::GetAtt': [ 'Boom', 'Arn' ] }); - test.deepEqual(stack.resolve(repo.repositoryName), { 'Fn::GetAtt': [ 'Boom', 'Name' ] }); + test.deepEqual(stack.resolve(repo.repositoryArn), { 'Fn::GetAtt': ['Boom', 'Arn'] }); + test.deepEqual(stack.resolve(repo.repositoryName), { 'Fn::GetAtt': ['Boom', 'Name'] }); test.done(); }, @@ -224,14 +226,14 @@ export = { // THEN test.deepEqual(stack.resolve(repo.repositoryArn), { - 'Fn::Join': [ '', [ + 'Fn::Join': ['', [ 'arn:', { Ref: 'AWS::Partition' }, ':ecr:', { Ref: 'AWS::Region' }, ':', { Ref: 'AWS::AccountId' }, - ':repository/my-repo' ] + ':repository/my-repo'] ] }); test.deepEqual(stack.resolve(repo.repositoryName), 'my-repo'); @@ -250,17 +252,17 @@ export = { }); // THEN - test.deepEqual(stack.resolve(repo.repositoryName), { 'Fn::GetAtt': [ 'Boom', 'Name' ] }); + test.deepEqual(stack.resolve(repo.repositoryName), { 'Fn::GetAtt': ['Boom', 'Name'] }); test.deepEqual(stack.resolve(repo.repositoryArn), { - 'Fn::Join': [ '', [ - 'arn:', - { Ref: 'AWS::Partition' }, - ':ecr:', - { Ref: 'AWS::Region' }, - ':', - { Ref: 'AWS::AccountId' }, - ':repository/', - { 'Fn::GetAtt': [ 'Boom', 'Name' ] } ] ] + 'Fn::Join': ['', [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':ecr:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':repository/', + { 'Fn::GetAtt': ['Boom', 'Name'] }]] }); test.done(); }, @@ -322,67 +324,173 @@ export = { })); test.done(); - } - }, + }, + 'onImageScanCompleted without imageTags creates the correct event'(test: Test) { + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); - 'removal policy is "Retain" by default'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); + repo.onImageScanCompleted('EventRule', { + target: { + bind: () => ({ arn: 'ARN', id: '' }) + } + }); - // WHEN - new ecr.Repository(stack, 'Repo'); + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + "EventPattern": { + "source": [ + "aws.ecr", + ], + "detail": { + "repository-name": [ + { + "Ref": "Repo02AC86CF" + } + ], + "scan-status": [ + "COMPLETE" + ] + } + }, + "State": "ENABLED", + })); - // THEN - expect(stack).to(haveResource('AWS::ECR::Repository', { - "Type": "AWS::ECR::Repository", - "DeletionPolicy": "Retain" - }, ResourcePart.CompleteDefinition)); - test.done(); - }, + test.done(); - '"Delete" removal policy can be set explicitly'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); + }, + 'onImageScanCompleted with one imageTag creates the correct event'(test: Test) { + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); - // WHEN - new ecr.Repository(stack, 'Repo', { - removalPolicy: RemovalPolicy.DESTROY - }); + repo.onImageScanCompleted('EventRule', { + imageTags: ['some-tag'], + target: { + bind: () => ({ arn: 'ARN', id: '' }) + } + }); - // THEN - expect(stack).to(haveResource('AWS::ECR::Repository', { - "Type": "AWS::ECR::Repository", - "DeletionPolicy": "Delete" - }, ResourcePart.CompleteDefinition)); - test.done(); - }, + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + "EventPattern": { + "source": [ + "aws.ecr", + ], + "detail": { + "repository-name": [ + { + "Ref": "Repo02AC86CF" + } + ], + "image-tags": [ + "some-tag" + ], + "scan-status": [ + "COMPLETE" + ] + } + }, + "State": "ENABLED", + })); - 'grant adds appropriate resource-*'(test: Test) { - // GIVEN - const stack = new Stack(); - const repo = new ecr.Repository(stack, 'TestHarnessRepo'); + test.done(); - // WHEN - repo.grantPull(new iam.AnyPrincipal()); + }, + 'onImageScanCompleted with multiple imageTags creates the correct event'(test: Test) { + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); - // THEN - expect(stack).to(haveResource('AWS::ECR::Repository', { - "RepositoryPolicyText": { - "Statement": [ - { - "Action": [ - "ecr:BatchCheckLayerAvailability", - "ecr:GetDownloadUrlForLayer", - "ecr:BatchGetImage" + repo.onImageScanCompleted('EventRule', { + imageTags: ['tag1', 'tag2', 'tag3'], + target: { + bind: () => ({ arn: 'ARN', id: '' }) + } + }); + + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + "EventPattern": { + "source": [ + "aws.ecr", + ], + "detail": { + "repository-name": [ + { + "Ref": "Repo02AC86CF" + } + ], + "image-tags": [ + "tag1", + "tag2", + "tag3" ], - "Effect": "Allow", - "Principal": "*", + "scan-status": [ + "COMPLETE" + ] } - ], - "Version": "2012-10-17" - } - })); + }, + "State": "ENABLED", + })); - test.done(); + test.done(); + + }, + + 'removal policy is "Retain" by default'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new ecr.Repository(stack, 'Repo'); + + // THEN + expect(stack).to(haveResource('AWS::ECR::Repository', { + "Type": "AWS::ECR::Repository", + "DeletionPolicy": "Retain" + }, ResourcePart.CompleteDefinition)); + test.done(); + }, + + '"Delete" removal policy can be set explicitly'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new ecr.Repository(stack, 'Repo', { + removalPolicy: RemovalPolicy.DESTROY + }); + + // THEN + expect(stack).to(haveResource('AWS::ECR::Repository', { + "Type": "AWS::ECR::Repository", + "DeletionPolicy": "Delete" + }, ResourcePart.CompleteDefinition)); + test.done(); + }, + + 'grant adds appropriate resource-*'(test: Test) { + // GIVEN + const stack = new Stack(); + const repo = new ecr.Repository(stack, 'TestHarnessRepo'); + + // WHEN + repo.grantPull(new iam.AnyPrincipal()); + + // THEN + expect(stack).to(haveResource('AWS::ECR::Repository', { + "RepositoryPolicyText": { + "Statement": [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Effect": "Allow", + "Principal": "*", + } + ], + "Version": "2012-10-17" + } + })); + + test.done(); + }, }, };