diff --git a/packages/@aws-cdk/aws-cloudformation/lib/pipeline-actions.ts b/packages/@aws-cdk/aws-cloudformation/lib/pipeline-actions.ts index af2ee8ae25e1d..169cf8045eb6e 100644 --- a/packages/@aws-cdk/aws-cloudformation/lib/pipeline-actions.ts +++ b/packages/@aws-cdk/aws-cloudformation/lib/pipeline-actions.ts @@ -33,6 +33,17 @@ export interface PipelineCloudFormationActionProps extends codepipeline.CommonAc * @default Automatically generated artifact name. */ outputArtifactName?: string; + + /** + * The AWS region the given Action resides in. + * Note that a cross-region Pipeline requires replication buckets to function correctly. + * You can provide their names with the {@link PipelineProps#crossRegionReplicationBuckets} property. + * If you don't, the CodePipeline Construct will create new Stacks in your CDK app containing those buckets, + * that you will need to `cdk deploy` before deploying the main, Pipeline-containing Stack. + * + * @default the Action resides in the same region as the Pipeline + */ + region?: string; } /** @@ -50,6 +61,7 @@ export abstract class PipelineCloudFormationAction extends codepipeline.Action { super(parent, id, { stage: props.stage, runOrder: props.runOrder, + region: props.region, artifactBounds: { minInputs: 0, maxInputs: 10, @@ -358,14 +370,6 @@ export enum CloudFormationCapabilities { NamedIAM = 'CAPABILITY_NAMED_IAM' } -function stackArnFromName(stackName: string): string { - return cdk.ArnUtils.fromComponents({ - service: 'cloudformation', - resource: 'stack', - resourceName: `${stackName}/*` - }); -} - /** * Manages a bunch of singleton-y statements on the policy of an IAM Role. * Dedicated methods can be used to add specific permissions to the role policy @@ -394,23 +398,14 @@ class SingletonPolicy extends cdk.Construct { super(role, SingletonPolicy.UUID); } - public grantCreateUpdateStack(props: { stackName: string, replaceOnFailure?: boolean }): void { - const actions = [ - 'cloudformation:DescribeStack*', - 'cloudformation:CreateStack', - 'cloudformation:UpdateStack', - 'cloudformation:GetTemplate*', - 'cloudformation:ValidateTemplate', - 'cloudformation:GetStackPolicy', - 'cloudformation:SetStackPolicy', - ]; - if (props.replaceOnFailure) { - actions.push('cloudformation:DeleteStack'); - } - this.statementFor({ actions }).addResource(stackArnFromName(props.stackName)); + public grantExecuteChangeSet(props: { stackName: string, changeSetName: string, region?: string }): void { + this.statementFor({ + actions: ['cloudformation:ExecuteChangeSet'], + conditions: { StringEquals: { 'cloudformation:ChangeSetName': props.changeSetName } }, + }).addResource(stackArnFromProps(props)); } - public grantCreateReplaceChangeSet(props: { stackName: string, changeSetName: string }): void { + public grantCreateReplaceChangeSet(props: { stackName: string, changeSetName: string, region?: string }): void { this.statementFor({ actions: [ 'cloudformation:CreateChangeSet', @@ -419,23 +414,32 @@ class SingletonPolicy extends cdk.Construct { 'cloudformation:DescribeStacks', ], conditions: { StringEqualsIfExists: { 'cloudformation:ChangeSetName': props.changeSetName } }, - }).addResource(stackArnFromName(props.stackName)); + }).addResource(stackArnFromProps(props)); } - public grantExecuteChangeSet(props: { stackName: string, changeSetName: string }): void { - this.statementFor({ - actions: ['cloudformation:ExecuteChangeSet'], - conditions: { StringEquals: { 'cloudformation:ChangeSetName': props.changeSetName } }, - }).addResource(stackArnFromName(props.stackName)); + public grantCreateUpdateStack(props: { stackName: string, replaceOnFailure?: boolean, region?: string }): void { + const actions = [ + 'cloudformation:DescribeStack*', + 'cloudformation:CreateStack', + 'cloudformation:UpdateStack', + 'cloudformation:GetTemplate*', + 'cloudformation:ValidateTemplate', + 'cloudformation:GetStackPolicy', + 'cloudformation:SetStackPolicy', + ]; + if (props.replaceOnFailure) { + actions.push('cloudformation:DeleteStack'); + } + this.statementFor({ actions }).addResource(stackArnFromProps(props)); } - public grantDeleteStack(props: { stackName: string }): void { + public grantDeleteStack(props: { stackName: string, region?: string }): void { this.statementFor({ actions: [ 'cloudformation:DescribeStack*', 'cloudformation:DeleteStack', ] - }).addResource(stackArnFromName(props.stackName)); + }).addResource(stackArnFromProps(props)); } public grantPassRole(role: iam.Role): void { @@ -481,3 +485,12 @@ interface StatementTemplate { } type StatementCondition = { [op: string]: { [attribute: string]: string } }; + +function stackArnFromProps(props: { stackName: string, region?: string }): string { + return cdk.ArnUtils.fromComponents({ + region: props.region, + service: 'cloudformation', + resource: 'stack', + resourceName: `${props.stackName}/*` + }); +} diff --git a/packages/@aws-cdk/aws-codepipeline-api/lib/action.ts b/packages/@aws-cdk/aws-codepipeline-api/lib/action.ts index 67df1ecfdcbce..9121a1da39cb3 100644 --- a/packages/@aws-cdk/aws-codepipeline-api/lib/action.ts +++ b/packages/@aws-cdk/aws-codepipeline-api/lib/action.ts @@ -153,6 +153,14 @@ export interface CommonActionConstructProps { export interface ActionProps extends CommonActionProps, CommonActionConstructProps { category: ActionCategory; provider: string; + + /** + * The region this Action resides in. + * + * @default the Action resides in the same region as the Pipeline + */ + region?: string; + artifactBounds: ActionArtifactBounds; configuration?: any; version?: string; @@ -177,6 +185,17 @@ export abstract class Action extends cdk.Construct { */ public readonly provider: string; + /** + * The AWS region the given Action resides in. + * Note that a cross-region Pipeline requires replication buckets to function correctly. + * You can provide their names with the {@link PipelineProps#crossRegionReplicationBuckets} property. + * If you don't, the CodePipeline Construct will create new Stacks in your CDK app containing those buckets, + * that you will need to `cdk deploy` before deploying the main, Pipeline-containing Stack. + * + * @default the Action resides in the same region as the Pipeline + */ + public readonly region?: string; + /** * The action's configuration. These are key-value pairs that specify input values for an action. * For more information, see the AWS CodePipeline User Guide. @@ -210,6 +229,7 @@ export abstract class Action extends cdk.Construct { this.version = props.version || '1'; this.category = props.category; this.provider = props.provider; + this.region = props.region; this.configuration = props.configuration; this.artifactBounds = props.artifactBounds; this.runOrder = props.runOrder === undefined ? 1 : props.runOrder; diff --git a/packages/@aws-cdk/aws-codepipeline/README.md b/packages/@aws-cdk/aws-codepipeline/README.md index 3cb57219713a4..bc9e7bc842599 100644 --- a/packages/@aws-cdk/aws-codepipeline/README.md +++ b/packages/@aws-cdk/aws-codepipeline/README.md @@ -74,6 +74,53 @@ new codepipeline.JenkinsBuildAction(this, 'Jenkins_Build', { }); ``` +### Cross-region CodePipelines + +You can also use the cross-region feature to deploy resources +(currently, only CloudFormation Stacks are supported) +into a different region than your Pipeline is in. + +It works like this: + +```ts +const pipeline = new codepipeline.Pipeline(this, 'MyFirstPipeline', { + // ... + crossRegionReplicationBuckets: { + 'us-west-1': 'my-us-west-1-replication-bucket', + }, +}); + +// later in the code... +new cloudformation.PipelineCreateUpdateStackAction(this, 'CFN_US_West_1', { + // ... + region: 'us-west-1', +}); +``` + +This way, the `CFN_US_West_1` Action will operate in the `us-west-1` region, +regardless of which region your Pipeline is in. + +If you don't provide a bucket name for a region (other than the Pipeline's region) +that you're using for an Action with the `crossRegionReplicationBuckets` property, +there will be a new Stack, named `aws-cdk-codepipeline-cross-region-scaffolding-`, +defined for you, containing a replication Bucket. +Note that you have to make sure to `cdk deploy` all of these automatically created Stacks +before you can deploy your main Stack (the one containing your Pipeline). +Use the `cdk ls` command to see all of the Stacks comprising your CDK application. +Example: + +```bash +$ cdk ls +MyMainStack +aws-cdk-codepipeline-cross-region-scaffolding-us-west-1 +$ cdk deploy aws-cdk-codepipeline-cross-region-scaffolding-us-west-1 +# output of cdk deploy here... +$ cdk deploy MyMainStack +``` + +See [the AWS docs here](https://docs.aws.amazon.com/codepipeline/latest/userguide/actions-create-cross-region.html) +for more information on cross-region CodePipelines. + ### Events #### Using a pipeline as an event target diff --git a/packages/@aws-cdk/aws-codepipeline/lib/cross-region-scaffold-stack.ts b/packages/@aws-cdk/aws-codepipeline/lib/cross-region-scaffold-stack.ts new file mode 100644 index 0000000000000..6f85db318f8ac --- /dev/null +++ b/packages/@aws-cdk/aws-codepipeline/lib/cross-region-scaffold-stack.ts @@ -0,0 +1,68 @@ +import s3 = require('@aws-cdk/aws-s3'); +import cdk = require('@aws-cdk/cdk'); +import crypto = require('crypto'); + +/** + * Construction properties for {@link CrossRegionScaffoldStack}. + */ +export interface CrossRegionScaffoldStackProps { + /** + * The AWS region this Stack resides in. + */ + region: string; + + /** + * The AWS account ID this Stack belongs to. + * + * @example '012345678901' + */ + account: string; +} + +/** + * A Stack containing resources required for the cross-region CodePipeline functionality to work. + */ +export class CrossRegionScaffoldStack extends cdk.Stack { + /** + * The name of the S3 Bucket used for replicating the Pipeline's artifacts into the region. + */ + public readonly replicationBucketName: string; + + constructor(parent?: cdk.App, props: CrossRegionScaffoldStackProps = defaultCrossRegionScaffoldStackProps()) { + super(parent, generateStackName(props), { + env: { + region: props.region, + account: props.account, + }, + }); + + const replicationBucketName = generateUniqueName('cdk-cross-region-codepipeline-replication-bucket-', + props.region, props.account, false, 12); + + new s3.Bucket(this, 'CrossRegionCodePipelineReplicationBucket', { + bucketName: replicationBucketName, + }); + this.replicationBucketName = replicationBucketName; + } +} + +function generateStackName(props: CrossRegionScaffoldStackProps): string { + return `aws-cdk-codepipeline-cross-region-scaffolding-${props.region}`; +} + +function generateUniqueName(baseName: string, region: string, account: string, + toUpperCase: boolean, hashPartLen: number = 8): string { + const sha256 = crypto.createHash('sha256') + .update(baseName) + .update(region) + .update(account); + + const hash = sha256.digest('hex').slice(0, hashPartLen); + + return baseName + (toUpperCase ? hash.toUpperCase() : hash.toLowerCase()); +} + +// purely to defeat the limitation that a required argument cannot follow an optional one +function defaultCrossRegionScaffoldStackProps(): CrossRegionScaffoldStackProps { + throw new Error('The props argument when creating a CrossRegionScaffoldStack is required'); +} diff --git a/packages/@aws-cdk/aws-codepipeline/lib/index.ts b/packages/@aws-cdk/aws-codepipeline/lib/index.ts index 4875bcc768b76..ca18a2780392d 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/index.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/index.ts @@ -1,3 +1,4 @@ +export * from './cross-region-scaffold-stack'; export * from './github-source-action'; export * from './manual-approval-action'; export * from './pipeline'; diff --git a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts index 2f432f07eb740..5399c01191cc7 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts @@ -4,6 +4,7 @@ import iam = require('@aws-cdk/aws-iam'); import s3 = require('@aws-cdk/aws-s3'); import cdk = require('@aws-cdk/cdk'); import { cloudformation } from './codepipeline.generated'; +import { CrossRegionScaffoldStack } from './cross-region-scaffold-stack'; import { CommonStageProps, Stage, StagePlacement } from './stage'; export interface PipelineProps { @@ -23,6 +24,16 @@ export interface PipelineProps { * and uses that for the pipeline name. */ pipelineName?: string; + + /** + * A map of region to S3 bucket name used for cross-region CodePipeline. + * For every Action that you specify targeting a different region than the Pipeline itself, + * if you don't provide an explicit Bucket for that region using this property, + * the construct will automatically create a scaffold Stack containing an S3 Bucket in that region. + * Note that you will have to `cdk deploy` that Stack before you can deploy your Pipeline-containing Stack. + * You can query the generated Stacks using the {@link Pipeline#crossRegionScaffoldStacks} property. + */ + crossRegionReplicationBuckets?: { [region: string]: string }; } /** @@ -72,6 +83,10 @@ export class Pipeline extends cdk.Construct implements cpapi.IPipeline { private readonly stages = new Array(); private eventsRole?: iam.Role; + private readonly pipelineResource: cloudformation.PipelineResource; + private readonly crossRegionReplicationBuckets: { [region: string]: string }; + private readonly artifactStores: { [region: string]: any }; + private readonly _crossRegionScaffoldStacks: { [region: string]: CrossRegionScaffoldStack } = {}; constructor(parent: cdk.Construct, name: string, props?: PipelineProps) { super(parent, name); @@ -107,6 +122,9 @@ export class Pipeline extends cdk.Construct implements cpapi.IPipeline { this.pipelineName = codePipeline.ref; this.pipelineVersion = codePipeline.pipelineVersion; + this.pipelineResource = codePipeline; + this.crossRegionReplicationBuckets = props.crossRegionReplicationBuckets || {}; + this.artifactStores = {}; // Does not expose a Fn::GetAtt for the ARN so we'll have to make it ourselves this.pipelineArn = cdk.ArnUtils.fromComponents({ @@ -224,6 +242,18 @@ export class Pipeline extends cdk.Construct implements cpapi.IPipeline { this.artifactBucket.grantReadWrite(identity); } + /** + * Returns all of the {@link CrossRegionScaffoldStack}s that were generated automatically + * when dealing with Actions that reside in a different region than the Pipeline itself. + */ + public get crossRegionScaffoldStacks(): { [region: string]: CrossRegionScaffoldStack } { + const ret: { [region: string]: CrossRegionScaffoldStack } = {}; + Object.keys(this._crossRegionScaffoldStacks).forEach((key) => { + ret[key] = this._crossRegionScaffoldStacks[key]; + }); + return ret; + } + /** * Adds a Stage to this Pipeline. * This is an internal operation - @@ -255,7 +285,49 @@ export class Pipeline extends cdk.Construct implements cpapi.IPipeline { // ignore unused private method (it's actually used in Stage) // @ts-ignore - private _generateOutputArtifactName(stage: cpapi.IStage, action: cpapi.Action): string { + private _attachActionToRegion(stage: Stage, action: actions.Action): void { + // handle cross-region Actions here + if (!action.region) { + return; + } + + // get the region the Pipeline itself is in + const pipelineStack = cdk.Stack.find(this); + const pipelineRegion = pipelineStack.requireRegion( + "You need to specify an explicit region when using CodePipeline's cross-region support"); + + // if we already have an ArtifactStore generated for this region, or it's the Pipeline's region, nothing to do + if (this.artifactStores[action.region] || action.region === pipelineRegion) { + return; + } + + let replicationBucketName = this.crossRegionReplicationBuckets[action.region]; + if (!replicationBucketName) { + const pipelineAccount = pipelineStack.requireAccountId( + "You need to specify an explicit account when using CodePipeline's cross-region support"); + const app = pipelineStack.parentApp(); + const crossRegionScaffoldStack = new CrossRegionScaffoldStack(app, { + region: action.region, + account: pipelineAccount, + }); + this._crossRegionScaffoldStacks[action.region] = crossRegionScaffoldStack; + replicationBucketName = crossRegionScaffoldStack.replicationBucketName; + } + + const replicationBucket = s3.BucketRef.import(this, 'CrossRegionCodePipelineReplicationBucket-' + action.region, { + bucketName: replicationBucketName, + }); + replicationBucket.grantReadWrite(this.role); + + this.artifactStores[action.region] = { + Location: replicationBucket.bucketName, + Type: 'S3', + }; + } + + // ignore unused private method (it's actually used in Stage) + // @ts-ignore + private _generateOutputArtifactName(stage: actions.IStage, action: actions.Action): string { // generate the artifact name based on the Action's full logical ID, // thus guaranteeing uniqueness return 'Artifact_' + action.uniqueId; @@ -382,6 +454,40 @@ export class Pipeline extends cdk.Construct implements cpapi.IPipeline { } private renderStages(): cloudformation.PipelineResource.StageDeclarationProperty[] { + // handle cross-region CodePipeline overrides here + let crossRegion = false; + this.stages.forEach((stage, i) => { + stage.actions.forEach((action, j) => { + if (action.region) { + crossRegion = true; + this.pipelineResource.addPropertyOverride(`Stages.${i}.Actions.${j}.Region`, action.region); + } + }); + }); + + if (crossRegion) { + // we don't need ArtifactStore in this case + this.pipelineResource.addPropertyDeletionOverride('ArtifactStore'); + + // add the Pipeline's artifact store + const artifactStore = this.renderArtifactStore(); + this.artifactStores[cdk.Stack.find(this).requireRegion()] = { + Location: artifactStore.location, + Type: artifactStore.type, + EncryptionKey: artifactStore.encryptionKey, + }; + + const artifactStoresProp: any[] = []; + // tslint:disable-next-line:forin + for (const region in this.artifactStores) { + artifactStoresProp.push({ + Region: region, + ArtifactStore: this.artifactStores[region], + }); + } + this.pipelineResource.addPropertyOverride('ArtifactStores', artifactStoresProp); + } + return this.stages.map(stage => stage.render()); } } diff --git a/packages/@aws-cdk/aws-codepipeline/lib/stage.ts b/packages/@aws-cdk/aws-codepipeline/lib/stage.ts index 2105aa741d35c..683ca1464bf9e 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/stage.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/stage.ts @@ -136,6 +136,8 @@ export class Stage extends cdk.Construct implements cpapi.IStage, cpapi.IInterna // _attachAction should be idempotent in case a customer ever calls it directly if (!this._actions.includes(action)) { this._actions.push(action); + + (this.pipeline as any)._attachActionToRegion(this, action); } } diff --git a/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn-cross-region.expected.json b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn-cross-region.expected.json new file mode 100644 index 0000000000000..f2f25bf9fcd04 --- /dev/null +++ b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn-cross-region.expected.json @@ -0,0 +1,252 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "Properties": { + "VersioningConfiguration": { + "Status": "Enabled" + } + } + }, + "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": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + }, + { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "CFNDeployRole68D5E8D3", + "Arn" + ] + } + }, + { + "Action": [ + "cloudformation:CreateStack", + "cloudformation:DescribeStack*", + "cloudformation:GetStackPolicy", + "cloudformation:GetTemplate*", + "cloudformation:SetStackPolicy", + "cloudformation:UpdateStack", + "cloudformation:ValidateTemplate" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":cloudformation:us-west-2:", + { + "Ref": "AWS::AccountId" + }, + ":stack/aws-cdk-codepipeline-cross-region-deploy-stack/*" + ] + ] + } + } + ], + "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": "S3", + "Version": "1" + }, + "Configuration": { + "S3Bucket": { + "Ref": "MyBucketF68F3FF0" + }, + "S3ObjectKey": "some/path", + "PollForSourceChanges": true + }, + "InputArtifacts": [], + "Name": "S3", + "OutputArtifacts": [ + { + "Name": "Artifact_awscdkcodepipelinecloudformationcrossregionMyBucketS3DBF7878C" + } + ], + "RunOrder": 1 + } + ], + "Name": "Source" + }, + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Deploy", + "Owner": "AWS", + "Provider": "CloudFormation", + "Version": "1" + }, + "Configuration": { + "StackName": "aws-cdk-codepipeline-cross-region-deploy-stack", + "ActionMode": "CREATE_UPDATE", + "TemplatePath": "Artifact_awscdkcodepipelinecloudformationcrossregionMyBucketS3DBF7878C::template.yml", + "RoleArn": { + "Fn::GetAtt": [ + "CFNDeployRole68D5E8D3", + "Arn" + ] + } + }, + "InputArtifacts": [ + { + "Name": "Artifact_awscdkcodepipelinecloudformationcrossregionMyBucketS3DBF7878C" + } + ], + "Name": "CFN_Deploy", + "OutputArtifacts": [], + "RunOrder": 1, + "Region": "us-west-2" + } + ], + "Name": "CFN" + } + ], + "ArtifactStores": [ + { + "Region": "us-west-2", + "ArtifactStore": { + "Location": { + "Ref": "MyBucketF68F3FF0" + }, + "Type": "S3" + } + } + ] + }, + "DependsOn": [ + "MyPipelineRoleC0D47CA4", + "MyPipelineRoleDefaultPolicy34F09EFA" + ] + }, + "CFNDeployRole68D5E8D3": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "cloudformation.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn-cross-region.ts b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn-cross-region.ts new file mode 100644 index 0000000000000..ea1748b395ec5 --- /dev/null +++ b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn-cross-region.ts @@ -0,0 +1,36 @@ +import cloudformation = require('@aws-cdk/aws-cloudformation'); +import s3 = require('@aws-cdk/aws-s3'); +import cdk = require('@aws-cdk/cdk'); +import codepipeline = require('../lib'); + +const app = new cdk.App(); + +const region = 'us-west-2'; // hardcode the region +const stack = new cdk.Stack(app, 'aws-cdk-codepipeline-cloudformation-cross-region', { + env: { + region, + }, +}); + +const bucket = new s3.Bucket(stack, 'MyBucket', { + versioned: true, +}); + +const pipeline = new codepipeline.Pipeline(stack, 'MyPipeline', { + artifactBucket: bucket, +}); + +const sourceStage = pipeline.addStage('Source'); +const sourceAction = bucket.addToPipeline(sourceStage, 'S3', { + bucketKey: 'some/path', +}); + +const cfnStage = pipeline.addStage('CFN'); +new cloudformation.PipelineCreateUpdateStackAction(stack, 'CFN_Deploy', { + stage: cfnStage, + stackName: 'aws-cdk-codepipeline-cross-region-deploy-stack', + templatePath: sourceAction.outputArtifact.atPath('template.yml'), + region, +}); + +app.run(); diff --git a/packages/@aws-cdk/aws-codepipeline/test/test.pipeline.ts b/packages/@aws-cdk/aws-codepipeline/test/test.pipeline.ts index 6f93d3d0ef53d..c5ad9900fd361 100644 --- a/packages/@aws-cdk/aws-codepipeline/test/test.pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline/test/test.pipeline.ts @@ -1,4 +1,5 @@ import { expect, haveResource } from '@aws-cdk/assert'; +import cloudformation = require('@aws-cdk/aws-cloudformation'); import codebuild = require('@aws-cdk/aws-codebuild'); import codecommit = require('@aws-cdk/aws-codecommit'); import lambda = require('@aws-cdk/aws-lambda'); @@ -360,8 +361,112 @@ export = { test.equal(sourceAction.configuration.PollForSourceChanges, false); test.done(); - } - } + }, + }, + + 'cross-region Pipeline': { + 'generates the required Action & ArtifactStores properties in the template'(test: Test) { + const pipelineRegion = 'us-west-2'; + const pipelineAccount = '123'; + + const stack = new cdk.Stack(undefined, undefined, { + env: { + region: pipelineRegion, + account: pipelineAccount, + }, + }); + const bucket = new s3.Bucket(stack, 'MyBucket'); + const pipeline = new codepipeline.Pipeline(stack, 'MyPipeline', { + crossRegionReplicationBuckets: { + 'us-west-1': 'sfo-replication-bucket', + }, + }); + + const stage1 = pipeline.addStage('Stage1'); + const sourceAction = bucket.addToPipeline(stage1, 'BucketSource', { + bucketKey: '/some/key', + }); + + const stage2 = pipeline.addStage('Stage2'); + new cloudformation.PipelineCreateReplaceChangeSetAction(stack, 'Action1', { + stage: stage2, + changeSetName: 'ChangeSet', + templatePath: sourceAction.outputArtifact.atPath('template.yaml'), + stackName: 'SomeStack', + region: pipelineRegion, + }); + new cloudformation.PipelineCreateUpdateStackAction(stack, 'Action2', { + stage: stage2, + templatePath: sourceAction.outputArtifact.atPath('template.yaml'), + stackName: 'OtherStack', + region: 'us-east-1', + }); + new cloudformation.PipelineExecuteChangeSetAction(stack, 'Action3', { + stage: stage2, + changeSetName: 'ChangeSet', + stackName: 'SomeStack', + region: 'us-west-1', + }); + + expect(stack).to(haveResource('AWS::CodePipeline::Pipeline', { + "ArtifactStores": [ + { + "Region": "us-east-1", + "ArtifactStore": { + "Type": "S3", + }, + }, + { + "Region": "us-west-1", + "ArtifactStore": { + "Location": "sfo-replication-bucket", + "Type": "S3", + }, + }, + { + "Region": "us-west-2", + "ArtifactStore": { + "Type": "S3", + }, + }, + ], + "Stages": [ + { + "Name": "Stage1", + }, + { + "Name": "Stage2", + "Actions": [ + { + "Name": "Action1", + "Region": "us-west-2", + }, + { + "Name": "Action2", + "Region": "us-east-1", + }, + { + "Name": "Action3", + "Region": "us-west-1", + }, + ], + }, + ] + })); + + test.equal(pipeline.crossRegionScaffoldStacks[pipelineRegion], undefined); + test.equal(pipeline.crossRegionScaffoldStacks['us-west-1'], undefined); + + const usEast1ScaffoldStack = pipeline.crossRegionScaffoldStacks['us-east-1']; + test.notEqual(usEast1ScaffoldStack, undefined); + test.equal(usEast1ScaffoldStack.env.region, 'us-east-1'); + test.equal(usEast1ScaffoldStack.env.account, pipelineAccount); + test.ok(usEast1ScaffoldStack.id.indexOf('us-east-1') !== -1, + `expected '${usEast1ScaffoldStack.id}' to contain 'us-east-1'`); + + test.done(); + }, + }, }; function stageForTesting(stack: cdk.Stack): codepipeline.Stage { diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts b/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts index 82cce8b4f1538..5e97d935a8ed6 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts @@ -174,6 +174,29 @@ export class Stack extends Construct { return this.env.region; } + /** + * Returns the AWS account ID of this Stack, + * or throws an exception if the account ID is not set in the environment. + * + * @param why more information about why is the account ID required + * @returns the AWS account ID of this Stack + */ + public requireAccountId(why?: string): string { + if (!this.env.account) { + throw new Error(`${why ? why + '. ' : ''}Stack requires account information. ` + + 'It can be supplied either via the "env" property when creating the Stack, or by using "aws configure"'); + } + + return this.env.account; + } + + public parentApp(): App | undefined { + const parent = this.parent; + return parent instanceof App + ? parent + : undefined; + } + /** * Indicate that a context key was expected *