From f7d7b162d7a1cc6b5cf551d8405ad90b733f9bb8 Mon Sep 17 00:00:00 2001 From: Pahud Date: Sat, 2 Nov 2019 12:31:32 +0800 Subject: [PATCH 1/4] feat(aws-ecr): add onImageScanCompleted() support(fix #4818) --- packages/@aws-cdk/aws-ecr/lib/repository.ts | 41 ++- .../test/integ.imagescan.expected.json | 85 ++++++ .../@aws-cdk/aws-ecr/test/integ.imagescan.ts | 15 + .../@aws-cdk/aws-ecr/test/test.repository.ts | 256 +++++++++++++----- packages/@aws-cdk/aws-events/lib/rule.ts | 27 +- 5 files changed, 345 insertions(+), 79 deletions(-) create mode 100644 packages/@aws-cdk/aws-ecr/test/integ.imagescan.expected.json create mode 100644 packages/@aws-cdk/aws-ecr/test/integ.imagescan.ts diff --git a/packages/@aws-cdk/aws-ecr/lib/repository.ts b/packages/@aws-cdk/aws-ecr/lib/repository.ts index 85071e61976e9..cf20eb592f264 100644 --- a/packages/@aws-cdk/aws-ecr/lib/repository.ts +++ b/packages/@aws-cdk/aws-ecr/lib/repository.ts @@ -80,6 +80,15 @@ 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; } /** @@ -170,7 +179,27 @@ 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: { + repositoryName: [this.repositoryName], + scanStatus: ['COMPLETE'], + imageTags: options.imageTags ? options.imageTags : undefined + } + }); + return rule; + } /** * Grant the given principal identity permissions to perform the actions on this repository */ @@ -225,6 +254,16 @@ export interface OnCloudTrailImagePushedOptions extends events.OnEventOptions { readonly imageTag?: string; } +export interface OnImageScanCompletedOptions extends events.OnEventOptions { + /** + * Only watch changes to the image tags spedified. + * Leave it undefined to watch the full repository. + * + * @default undefined + */ + 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(); + }, }, }; diff --git a/packages/@aws-cdk/aws-events/lib/rule.ts b/packages/@aws-cdk/aws-events/lib/rule.ts index 52bcfe83f8c91..759ad8fd449cb 100644 --- a/packages/@aws-cdk/aws-events/lib/rule.ts +++ b/packages/@aws-cdk/aws-events/lib/rule.ts @@ -91,12 +91,12 @@ export class Rule extends Resource implements IRule { public readonly ruleName: string; private readonly targets = new Array(); - private readonly eventPattern: EventPattern = { }; + private readonly eventPattern: EventPattern = {}; private readonly scheduleExpression?: string; private readonly description?: string; private readonly accountEventBusTargets: { [account: string]: boolean } = {}; - constructor(scope: Construct, id: string, props: RuleProps = { }) { + constructor(scope: Construct, id: string, props: RuleProps = {}) { super(scope, id, { physicalName: props.ruleName, }); @@ -341,15 +341,34 @@ export class Rule extends Resource implements IRule { out[key] = value; } + // rename keys in the 'detail' map + const origdetail = out.detail; + const detailout: any = {}; + for (let key of Object.keys(origdetail)) { + const value = (origdetail as any)[key]; + if (key === 'repositoryName') { + key = 'repository-name'; + } + if (key === 'scanStatus') { + key = 'scan-status'; + } + if (key === 'imageTags') { + key = 'image-tags'; + } + detailout[key] = value; + } + + out.detail = detailout; + return out; } protected validate() { if (Object.keys(this.eventPattern).length === 0 && !this.scheduleExpression) { - return [ `Either 'eventPattern' or 'schedule' must be defined` ]; + return [`Either 'eventPattern' or 'schedule' must be defined`]; } - return [ ]; + return []; } private renderTargets() { From d1a0cdf7013c79caa1c26a16a494e5b6778da693 Mon Sep 17 00:00:00 2001 From: Pahud Date: Sat, 2 Nov 2019 14:02:33 +0800 Subject: [PATCH 2/4] fix detail validation in the eventPattern payload. --- packages/@aws-cdk/aws-events/lib/rule.ts | 32 ++++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/@aws-cdk/aws-events/lib/rule.ts b/packages/@aws-cdk/aws-events/lib/rule.ts index 759ad8fd449cb..03d443dbc5b11 100644 --- a/packages/@aws-cdk/aws-events/lib/rule.ts +++ b/packages/@aws-cdk/aws-events/lib/rule.ts @@ -342,24 +342,24 @@ export class Rule extends Resource implements IRule { } // rename keys in the 'detail' map - const origdetail = out.detail; - const detailout: any = {}; - for (let key of Object.keys(origdetail)) { - const value = (origdetail as any)[key]; - if (key === 'repositoryName') { - key = 'repository-name'; - } - if (key === 'scanStatus') { - key = 'scan-status'; - } - if (key === 'imageTags') { - key = 'image-tags'; + if (out.detail && Object.keys(out.detail).length > 0) { + const origdetail = out.detail; + const detailout: any = {}; + for (let key of Object.keys(origdetail)) { + const value = (origdetail as any)[key]; + if (key === 'repositoryName') { + key = 'repository-name'; + } + if (key === 'scanStatus') { + key = 'scan-status'; + } + if (key === 'imageTags') { + key = 'image-tags'; + } + detailout[key] = value; } - detailout[key] = value; + out.detail = detailout; } - - out.detail = detailout; - return out; } From c984b9723b2d84ecd7b95d2df1bf21e7585ce6a9 Mon Sep 17 00:00:00 2001 From: Pahud Date: Sat, 2 Nov 2019 21:15:28 +0800 Subject: [PATCH 3/4] fix awslint errors --- packages/@aws-cdk/aws-ecr/lib/repository.ts | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/@aws-cdk/aws-ecr/lib/repository.ts b/packages/@aws-cdk/aws-ecr/lib/repository.ts index cf20eb592f264..c2aef6a5f6154 100644 --- a/packages/@aws-cdk/aws-ecr/lib/repository.ts +++ b/packages/@aws-cdk/aws-ecr/lib/repository.ts @@ -89,6 +89,12 @@ export interface IRepository extends IResource { * @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; } /** @@ -200,6 +206,20 @@ export abstract class RepositoryBase extends Resource implements IRepository { }); 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 */ @@ -254,6 +274,9 @@ 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. From 28fb0bb6e9385cbc3718e595f89347877a758031 Mon Sep 17 00:00:00 2001 From: Pahud Date: Mon, 4 Nov 2019 21:11:26 +0800 Subject: [PATCH 4/4] - minor update the repository.ts w/o touching the rule.ts --- packages/@aws-cdk/aws-ecr/lib/repository.ts | 8 +++--- packages/@aws-cdk/aws-events/lib/rule.ts | 27 +++------------------ 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/packages/@aws-cdk/aws-ecr/lib/repository.ts b/packages/@aws-cdk/aws-ecr/lib/repository.ts index c2aef6a5f6154..8d44e27ec7526 100644 --- a/packages/@aws-cdk/aws-ecr/lib/repository.ts +++ b/packages/@aws-cdk/aws-ecr/lib/repository.ts @@ -199,9 +199,9 @@ export abstract class RepositoryBase extends Resource implements IRepository { source: ['aws.ecr'], detailType: ['ECR Image Scan'], detail: { - repositoryName: [this.repositoryName], - scanStatus: ['COMPLETE'], - imageTags: options.imageTags ? options.imageTags : undefined + 'repository-name': [this.repositoryName], + 'scan-status': ['COMPLETE'], + 'image-tags': options.imageTags ? options.imageTags : undefined } }); return rule; @@ -282,7 +282,7 @@ export interface OnImageScanCompletedOptions extends events.OnEventOptions { * Only watch changes to the image tags spedified. * Leave it undefined to watch the full repository. * - * @default undefined + * @default - Watch the changes to the repository with all image tags */ readonly imageTags?: string[]; } diff --git a/packages/@aws-cdk/aws-events/lib/rule.ts b/packages/@aws-cdk/aws-events/lib/rule.ts index 03d443dbc5b11..52bcfe83f8c91 100644 --- a/packages/@aws-cdk/aws-events/lib/rule.ts +++ b/packages/@aws-cdk/aws-events/lib/rule.ts @@ -91,12 +91,12 @@ export class Rule extends Resource implements IRule { public readonly ruleName: string; private readonly targets = new Array(); - private readonly eventPattern: EventPattern = {}; + private readonly eventPattern: EventPattern = { }; private readonly scheduleExpression?: string; private readonly description?: string; private readonly accountEventBusTargets: { [account: string]: boolean } = {}; - constructor(scope: Construct, id: string, props: RuleProps = {}) { + constructor(scope: Construct, id: string, props: RuleProps = { }) { super(scope, id, { physicalName: props.ruleName, }); @@ -341,34 +341,15 @@ export class Rule extends Resource implements IRule { out[key] = value; } - // rename keys in the 'detail' map - if (out.detail && Object.keys(out.detail).length > 0) { - const origdetail = out.detail; - const detailout: any = {}; - for (let key of Object.keys(origdetail)) { - const value = (origdetail as any)[key]; - if (key === 'repositoryName') { - key = 'repository-name'; - } - if (key === 'scanStatus') { - key = 'scan-status'; - } - if (key === 'imageTags') { - key = 'image-tags'; - } - detailout[key] = value; - } - out.detail = detailout; - } return out; } protected validate() { if (Object.keys(this.eventPattern).length === 0 && !this.scheduleExpression) { - return [`Either 'eventPattern' or 'schedule' must be defined`]; + return [ `Either 'eventPattern' or 'schedule' must be defined` ]; } - return []; + return [ ]; } private renderTargets() {