From 01cc8a24b890fa5257678462ffb6696ad4b37ae6 Mon Sep 17 00:00:00 2001 From: Adam Ruka Date: Wed, 28 Nov 2018 00:03:11 -0800 Subject: [PATCH] feat(aws-ecr): add an ECR Repository source CodePipeline Action. (#1255) --- .../@aws-cdk/aws-codepipeline/package.json | 3 +- .../integ.pipeline-ecr-source.expected.json | 269 ++++++++++++++++++ .../test/integ.pipeline-ecr-source.ts | 24 ++ packages/@aws-cdk/aws-ecr/README.md | 25 ++ packages/@aws-cdk/aws-ecr/lib/index.ts | 1 + .../@aws-cdk/aws-ecr/lib/pipeline-action.ts | 62 ++++ .../@aws-cdk/aws-ecr/lib/repository-ref.ts | 43 ++- packages/@aws-cdk/aws-ecr/package.json | 6 +- .../@aws-cdk/aws-ecr/test/test.repository.ts | 35 ++- 9 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-ecr-source.expected.json create mode 100644 packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-ecr-source.ts create mode 100644 packages/@aws-cdk/aws-ecr/lib/pipeline-action.ts diff --git a/packages/@aws-cdk/aws-codepipeline/package.json b/packages/@aws-cdk/aws-codepipeline/package.json index 10dc0c979b784..958b3c0015916 100644 --- a/packages/@aws-cdk/aws-codepipeline/package.json +++ b/packages/@aws-cdk/aws-codepipeline/package.json @@ -63,6 +63,7 @@ "@aws-cdk/aws-codebuild": "^0.18.1", "@aws-cdk/aws-codecommit": "^0.18.1", "@aws-cdk/aws-codedeploy": "^0.18.1", + "@aws-cdk/aws-ecr": "^0.18.1", "@aws-cdk/aws-lambda": "^0.18.1", "@aws-cdk/aws-sns": "^0.18.1", "cdk-build-tools": "^0.18.1", @@ -85,4 +86,4 @@ "@aws-cdk/aws-s3": "^0.18.1", "@aws-cdk/cdk": "^0.18.1" } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-ecr-source.expected.json b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-ecr-source.expected.json new file mode 100644 index 0000000000000..6236aba51716e --- /dev/null +++ b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-ecr-source.expected.json @@ -0,0 +1,269 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket" + }, + "MyPipelineRoleC0D47CA4": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "codepipeline.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyPipelineRoleDefaultPolicy34F09EFA": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + "s3:PutObject*", + "s3:Abort*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + }, + { + "Action": "ecr:DescribeImages", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "MyEcrRepo767466D0", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyPipelineRoleDefaultPolicy34F09EFA", + "Roles": [ + { + "Ref": "MyPipelineRoleC0D47CA4" + } + ] + } + }, + "MyPipelineAED38ECF": { + "Type": "AWS::CodePipeline::Pipeline", + "Properties": { + "RoleArn": { + "Fn::GetAtt": [ + "MyPipelineRoleC0D47CA4", + "Arn" + ] + }, + "Stages": [ + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Source", + "Owner": "AWS", + "Provider": "ECR", + "Version": "1" + }, + "Configuration": { + "RepositoryName": { + "Ref": "MyEcrRepo767466D0" + } + }, + "InputArtifacts": [], + "Name": "ECR_Source", + "OutputArtifacts": [ + { + "Name": "Artifact_awscdkcodepipelineecrsourceMyEcrRepoECRSource8525F033" + } + ], + "RunOrder": 1 + } + ], + "Name": "Source" + }, + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Approval", + "Owner": "AWS", + "Provider": "Manual", + "Version": "1" + }, + "InputArtifacts": [], + "Name": "ManualApproval", + "OutputArtifacts": [], + "RunOrder": 1 + } + ], + "Name": "Approve" + } + ], + "ArtifactStore": { + "Location": { + "Ref": "MyBucketF68F3FF0" + }, + "Type": "S3" + } + }, + "DependsOn": [ + "MyPipelineRoleC0D47CA4", + "MyPipelineRoleDefaultPolicy34F09EFA" + ] + }, + "MyPipelineEventsRoleFAB99F32": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyPipelineEventsRoleDefaultPolicyF045F033": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "codepipeline:StartPipelineExecution", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":codepipeline:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyPipelineAED38ECF" + } + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyPipelineEventsRoleDefaultPolicyF045F033", + "Roles": [ + { + "Ref": "MyPipelineEventsRoleFAB99F32" + } + ] + } + }, + "MyEcrRepo767466D0": { + "Type": "AWS::ECR::Repository" + }, + "MyEcrRepoawscdkcodepipelineecrsourceMyPipeline63CF3194SourceEventRule911FDB6D": { + "Type": "AWS::Events::Rule", + "Properties": { + "EventPattern": { + "source": [ + "aws.ecr" + ], + "detail": { + "eventName": [ + "PutImage" + ], + "requestParameters": { + "repositoryName": [ + { + "Ref": "MyEcrRepo767466D0" + } + ] + } + } + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":codepipeline:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyPipelineAED38ECF" + } + ] + ] + }, + "Id": "MyPipeline", + "RoleArn": { + "Fn::GetAtt": [ + "MyPipelineEventsRoleFAB99F32", + "Arn" + ] + } + } + ] + } + } + } +} diff --git a/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-ecr-source.ts b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-ecr-source.ts new file mode 100644 index 0000000000000..27a1c8c760c6b --- /dev/null +++ b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-ecr-source.ts @@ -0,0 +1,24 @@ +import ecr = require('@aws-cdk/aws-ecr'); +import s3 = require('@aws-cdk/aws-s3'); +import cdk = require('@aws-cdk/cdk'); +import codepipeline = require('../lib'); + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-codepipeline-ecr-source'); + +const bucket = new s3.Bucket(stack, 'MyBucket'); +const pipeline = new codepipeline.Pipeline(stack, 'MyPipeline', { + artifactBucket: bucket, +}); + +const repository = new ecr.Repository(stack, 'MyEcrRepo'); +const sourceStage = pipeline.addStage('Source'); +repository.addToPipeline(sourceStage, 'ECR_Source'); + +const approveStage = pipeline.addStage('Approve'); +new codepipeline.ManualApprovalAction(stack, 'ManualApproval', { + stage: approveStage, +}); + +app.run(); diff --git a/packages/@aws-cdk/aws-ecr/README.md b/packages/@aws-cdk/aws-ecr/README.md index 93a7e841a25bf..cefec43725fbb 100644 --- a/packages/@aws-cdk/aws-ecr/README.md +++ b/packages/@aws-cdk/aws-ecr/README.md @@ -23,3 +23,28 @@ is important here): repository.addLifecycleRule({ tagPrefixList: ['prod'], maxImageCount: 9999 }); repository.addLifecycleRule({ maxImageAgeDays: 30 }); ``` + +### Using with CodePipeline + +This package also contains a source Action that allows you to use an ECR Repository as a source for CodePipeline. +Example: + +```ts +import codepipeline = require('@aws-cdk/aws-codepipeline'); + +const pipeline = new codepipeline.Pipeline(this, 'MyPipeline'); +const sourceStage = pipeline.addStage('Source'); +const sourceAction = new ecr.PipelineSourceAction(this, 'ECR', { + stage: sourceStage, + repository: ecrRepository, + imageTag: 'some-tag', // optional, default: 'latest' + outputArtifactName: 'SomeName', // optional +}); +``` + +You can also add the Repository to the Pipeline directly: + +```ts +// equivalent to the code above: +const sourceAction = ecrRepository.addToPipeline(sourceStage, 'ECR'); +``` diff --git a/packages/@aws-cdk/aws-ecr/lib/index.ts b/packages/@aws-cdk/aws-ecr/lib/index.ts index 21b453e140916..39da13ce26a30 100644 --- a/packages/@aws-cdk/aws-ecr/lib/index.ts +++ b/packages/@aws-cdk/aws-ecr/lib/index.ts @@ -1,6 +1,7 @@ // AWS::ECR CloudFormation Resources: export * from './ecr.generated'; +export * from './pipeline-action'; export * from './repository'; export * from './repository-ref'; export * from './lifecycle'; diff --git a/packages/@aws-cdk/aws-ecr/lib/pipeline-action.ts b/packages/@aws-cdk/aws-ecr/lib/pipeline-action.ts new file mode 100644 index 0000000000000..a08d4a5a16805 --- /dev/null +++ b/packages/@aws-cdk/aws-ecr/lib/pipeline-action.ts @@ -0,0 +1,62 @@ +import codepipeline = require('@aws-cdk/aws-codepipeline-api'); +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import { RepositoryRef } from './repository-ref'; + +/** + * Common properties for the {@link PipelineSourceAction CodePipeline source Action}, + * whether creating it directly, + * or through the {@link RepositoryRef#addToPipeline} method. + */ +export interface CommonPipelineSourceActionProps extends codepipeline.CommonActionProps { + /** + * The image tag that will be checked for changes. + * + * @default 'latest' + */ + imageTag?: string; + + /** + * The name of the source's output artifact. + * Output artifacts are used by CodePipeline as inputs into other actions. + * + * @default a name will be auto-generated + */ + outputArtifactName?: string; +} + +/** + * Construction properties of {@link PipelineSourceAction}. + */ +export interface PipelineSourceActionProps extends CommonPipelineSourceActionProps, + codepipeline.CommonActionConstructProps { + /** + * The repository that will be watched for changes. + */ + repository: RepositoryRef; +} + +/** + * The ECR Repository source CodePipeline Action. + */ +export class PipelineSourceAction extends codepipeline.SourceAction { + constructor(parent: cdk.Construct, name: string, props: PipelineSourceActionProps) { + super(parent, name, { + provider: 'ECR', + configuration: { + RepositoryName: props.repository.repositoryName, + ImageTag: props.imageTag, + }, + ...props, + }); + + props.stage.pipeline.role.addToPolicy(new iam.PolicyStatement() + .addActions( + 'ecr:DescribeImages', + ) + .addResource(props.repository.repositoryArn)); + + props.repository.onImagePushed(props.stage.pipeline.uniqueId + 'SourceEventRule', + props.stage.pipeline, props.imageTag); + } +} diff --git a/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts b/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts index 2e41764d145c0..960606f8fac04 100644 --- a/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts +++ b/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts @@ -1,5 +1,8 @@ +import codepipeline = require('@aws-cdk/aws-codepipeline-api'); +import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); import cdk = require('@aws-cdk/cdk'); +import { CommonPipelineSourceActionProps, PipelineSourceAction } from './pipeline-action'; /** * An ECR repository @@ -45,6 +48,44 @@ export abstract class RepositoryRef extends cdk.Construct { return `${parts.account}.dkr.ecr.${parts.region}.amazonaws.com/${parts.resourceName}`; } + /** + * Convenience method for creating a new {@link PipelineSourceAction}, + * and adding it to the given Stage. + * + * @param stage the Pipeline Stage to add the new Action to + * @param name the name of the newly created Action + * @param props the optional construction properties of the new Action + * @returns the newly created {@link PipelineSourceAction} + */ + public addToPipeline(stage: codepipeline.IStage, name: string, props: CommonPipelineSourceActionProps = {}): + PipelineSourceAction { + return new PipelineSourceAction(this, name, { + stage, + repository: this, + ...props, + }); + } + + public onImagePushed(name: string, target?: events.IEventRuleTarget, imageTag?: string): events.EventRule { + return new events.EventRule(this, name, { + targets: target ? [target] : undefined, + eventPattern: { + source: ['aws.ecr'], + detail: { + eventName: [ + 'PutImage', + ], + requestParameters: { + repositoryName: [ + this.repositoryName, + ], + imageTag: imageTag ? [imageTag] : undefined, + }, + }, + }, + }); + } + /** * Grant the given principal identity permissions to perform the actions on this repository */ @@ -91,4 +132,4 @@ class ImportedRepository extends RepositoryRef { public addToResourcePolicy(_statement: iam.PolicyStatement) { // FIXME: Add annotation about policy we dropped on the floor } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-ecr/package.json b/packages/@aws-cdk/aws-ecr/package.json index f4ceef452c7ca..f4a3447161f54 100644 --- a/packages/@aws-cdk/aws-ecr/package.json +++ b/packages/@aws-cdk/aws-ecr/package.json @@ -59,12 +59,16 @@ "pkglint": "^0.18.1" }, "dependencies": { + "@aws-cdk/aws-codepipeline-api": "^0.18.1", + "@aws-cdk/aws-events": "^0.18.1", "@aws-cdk/aws-iam": "^0.18.1", "@aws-cdk/cdk": "^0.18.1" }, "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-codepipeline-api": "^0.18.1", + "@aws-cdk/aws-events": "^0.18.1", "@aws-cdk/aws-iam": "^0.18.1", "@aws-cdk/cdk": "^0.18.1" } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-ecr/test/test.repository.ts b/packages/@aws-cdk/aws-ecr/test/test.repository.ts index 39201bb8dbed3..c670eec05a699 100644 --- a/packages/@aws-cdk/aws-ecr/test/test.repository.ts +++ b/packages/@aws-cdk/aws-ecr/test/test.repository.ts @@ -4,6 +4,8 @@ import cdk = require('@aws-cdk/cdk'); import { Test } from 'nodeunit'; import ecr = require('../lib'); +// tslint:disable:object-literal-key-quotes + export = { 'construct repository'(test: Test) { // GIVEN @@ -186,5 +188,36 @@ export = { })); test.done(); - } + }, + + 'events': { + 'onImagePushed without target or imageTag creates the correct event'(test: Test) { + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); + + repo.onImagePushed('EventRule'); + + expect(stack).to(haveResource('AWS::Events::Rule', { + "EventPattern": { + "source": [ + "aws.ecr", + ], + "detail": { + "eventName": [ + "PutImage", + ], + "requestParameters": { + "repositoryName": [ + { + }, + ], + }, + }, + }, + "State": "ENABLED", + })); + + test.done(); + } + }, };