Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(codepipeline): use a special bootstrapless synthesizer for cross-region support Stacks #8091

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export interface CrossRegionSupportStackProps {
* @example '012345678901'
*/
readonly account: string;

readonly synthesizer: cdk.IStackSynthesizer | undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a ? is the more usual way we express that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I actually wanted to make the synthesizer a required property.

I don't think it matters much, this is all private to the codepipeline module anyway.

}

/**
Expand All @@ -90,6 +92,7 @@ export class CrossRegionSupportStack extends cdk.Stack {
region: props.region,
account: props.account,
},
synthesizer: props.synthesizer,
});

const crossRegionSupportConstruct = new CrossRegionSupportConstruct(this, 'Default');
Expand Down
23 changes: 22 additions & 1 deletion packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import * as events from '@aws-cdk/aws-events';
import * as iam from '@aws-cdk/aws-iam';
import * as kms from '@aws-cdk/aws-kms';
import * as s3 from '@aws-cdk/aws-s3';
import { App, Construct, Lazy, PhysicalName, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/core';
import {
App, BootstraplessSynthesizer, Construct, DefaultStackSynthesizer,
IStackSynthesizer, Lazy, PhysicalName, RemovalPolicy, Resource, Stack, Token,
} from '@aws-cdk/core';
import { ActionCategory, IAction, IPipeline, IStage } from './action';
import { CfnPipeline } from './codepipeline.generated';
import { CrossRegionSupportConstruct, CrossRegionSupportStack } from './cross-region-support-stack';
Expand Down Expand Up @@ -483,6 +486,7 @@ export class Pipeline extends PipelineBase {
pipelineStackName: pipelineStack.stackName,
region: actionRegion,
account: pipelineAccount,
synthesizer: this.getCrossRegionSupportSynthesizer(),
});
}

Expand All @@ -492,6 +496,23 @@ export class Pipeline extends PipelineBase {
};
}

private getCrossRegionSupportSynthesizer(): IStackSynthesizer | undefined {
if (this.stack.synthesizer instanceof DefaultStackSynthesizer) {
// if we have the new synthesizer,
// we need a bootstrapless copy of it,
// because we don't want to require bootstrapping the environment
// of the pipeline account in this replication region
return new BootstraplessSynthesizer({
deployRoleArn: this.stack.synthesizer.deployRoleArn,
cloudFormationExecutionRoleArn: this.stack.synthesizer.cloudFormationExecutionRoleArn,
});
} else {
// any other synthesizer: just return undefined
// (ie., use the default based on the context settings)
return undefined;
}
}

private generateNameForDefaultBucketKeyAlias(): string {
const prefix = 'alias/codepipeline-';
const maxAliasLength = 256;
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-codepipeline/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"license": "Apache-2.0",
"devDependencies": {
"@aws-cdk/assert": "0.0.0",
"@aws-cdk/cx-api": "0.0.0",
"@types/nodeunit": "^0.0.31",
"cdk-build-tools": "0.0.0",
"cdk-integ-tools": "0.0.0",
Expand Down
43 changes: 42 additions & 1 deletion packages/@aws-cdk/aws-codepipeline/test/test.pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as iam from '@aws-cdk/aws-iam';
import * as kms from '@aws-cdk/aws-kms';
import * as s3 from '@aws-cdk/aws-s3';
import * as cdk from '@aws-cdk/core';
import * as cxapi from '@aws-cdk/cx-api';
import { Test } from 'nodeunit';
import * as codepipeline from '../lib';
import { FakeBuildAction } from './fake-build-action';
Expand Down Expand Up @@ -46,7 +47,7 @@ export = {
},

'that is cross-region': {
'validates that source actions are in the same account as the pipeline'(test: Test) {
'validates that source actions are in the same region as the pipeline'(test: Test) {
const app = new cdk.App();
const stack = new cdk.Stack(app, 'PipelineStack', { env: { region: 'us-west-1', account: '123456789012' }});
const pipeline = new codepipeline.Pipeline(stack, 'Pipeline');
Expand Down Expand Up @@ -296,6 +297,46 @@ export = {

test.done();
},

'generates the support stack containing the replication Bucket without the need to bootstrap in that environment'(test: Test) {
const app = new cdk.App({
treeMetadata: false, // we can't set the context otherwise, because App will have a child
});
app.node.setContext(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT, true);

const pipelineStack = new cdk.Stack(app, 'PipelineStack', {
env: { region: 'us-west-2', account: '123456789012' },
});
const sourceOutput = new codepipeline.Artifact();
new codepipeline.Pipeline(pipelineStack, 'Pipeline', {
stages: [
{
stageName: 'Source',
actions: [new FakeSourceAction({
actionName: 'Source',
output: sourceOutput,
})],
},
{
stageName: 'Build',
actions: [new FakeBuildAction({
actionName: 'Build',
input: sourceOutput,
region: 'eu-south-1',
})],
},
],
});

const assembly = app.synth();
const supportStackArtifact = assembly.getStackByName('PipelineStack-support-eu-south-1');
test.equal(supportStackArtifact.assumeRoleArn,
'arn:${AWS::Partition}:iam::123456789012:role/cdk-hnb659fds-deploy-role-123456789012-us-west-2');
test.equal(supportStackArtifact.cloudFormationExecutionRoleArn,
'arn:${AWS::Partition}:iam::123456789012:role/cdk-hnb659fds-cfn-exec-role-123456789012-us-west-2');

test.done();
},
},

'that is cross-account': {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets';
import { ISynthesisSession } from '../construct-compat';
import { addStackArtifactToAssembly, assertBound } from './_shared';
import { DefaultStackSynthesizer } from './default-synthesizer';

/**
* Construction properties of {@link BootstraplessSynthesizer}.
*/
export interface BootstraplessSynthesizerProps {
/**
* The deploy Role ARN to use.
*
* @default - No deploy role (use CLI credentials)
*
*/
readonly deployRoleArn?: string;

/**
* The CFN execution Role ARN to use.
*
* @default - No CloudFormation role (use CLI credentials)
*/
readonly cloudFormationExecutionRoleArn?: string;
}

/**
* A special synthesizer that behaves similarly to DefaultStackSynthesizer,
* but doesn't require bootstrapping the environment it operates in.
* Because of that, stacks using it cannot have assets inside of them.
* Used by the CodePipeline construct for the support stacks needed for
* cross-region replication S3 buckets.
*/
export class BootstraplessSynthesizer extends DefaultStackSynthesizer {
constructor(props: BootstraplessSynthesizerProps) {
super({
deployRoleArn: props.deployRoleArn,
cloudFormationExecutionRole: props.cloudFormationExecutionRoleArn,
});
}

public addFileAsset(_asset: FileAssetSource): FileAssetLocation {
throw new Error('Cannot add assets to a Stack that uses the BootstraplessSynthesizer');
}

public addDockerImageAsset(_asset: DockerImageAssetSource): DockerImageAssetLocation {
throw new Error('Cannot add assets to a Stack that uses the BootstraplessSynthesizer');
}

public synthesizeStackArtifacts(session: ISynthesisSession): void {
assertBound(this.stack);

// do _not_ treat the template as an asset,
// because this synthesizer doesn't have a bootstrap bucket to put it in
addStackArtifactToAssembly(session, this.stack, {
assumeRoleArn: this.deployRoleArn,
cloudFormationExecutionRoleArn: this.cloudFormationExecutionRoleArn,
requiresBootstrapStackVersion: 1,
}, []);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,11 @@ export class DefaultStackSynthesizer implements IStackSynthesizer {
*/
public static readonly DEFAULT_FILE_ASSETS_BUCKET_NAME = 'cdk-${Qualifier}-assets-${AWS::AccountId}-${AWS::Region}';

private stack?: Stack;
private _stack?: Stack;
private bucketName?: string;
private repositoryName?: string;
private deployRoleArn?: string;
private cloudFormationExecutionRoleArn?: string;
private _deployRoleArn?: string;
private _cloudFormationExecutionRoleArn?: string;
private assetPublishingRoleArn?: string;

private readonly files: NonNullable<asset_schema.ManifestFile['files']> = {};
Expand All @@ -154,7 +154,7 @@ export class DefaultStackSynthesizer implements IStackSynthesizer {
}

public bind(stack: Stack): void {
this.stack = stack;
this._stack = stack;

const qualifier = this.props.qualifier ?? stack.node.tryGetContext(BOOTSTRAP_QUALIFIER_CONTEXT) ?? DefaultStackSynthesizer.DEFAULT_QUALIFIER;

Expand All @@ -176,8 +176,8 @@ export class DefaultStackSynthesizer implements IStackSynthesizer {
// tslint:disable:max-line-length
this.bucketName = specialize(this.props.fileAssetsBucketName ?? DefaultStackSynthesizer.DEFAULT_FILE_ASSETS_BUCKET_NAME);
this.repositoryName = specialize(this.props.imageAssetsRepositoryName ?? DefaultStackSynthesizer.DEFAULT_IMAGE_ASSETS_REPOSITORY_NAME);
this.deployRoleArn = specialize(this.props.deployRoleArn ?? DefaultStackSynthesizer.DEFAULT_DEPLOY_ROLE_ARN);
this.cloudFormationExecutionRoleArn = specialize(this.props.cloudFormationExecutionRole ?? DefaultStackSynthesizer.DEFAULT_CLOUDFORMATION_ROLE_ARN);
this._deployRoleArn = specialize(this.props.deployRoleArn ?? DefaultStackSynthesizer.DEFAULT_DEPLOY_ROLE_ARN);
this._cloudFormationExecutionRoleArn = specialize(this.props.cloudFormationExecutionRole ?? DefaultStackSynthesizer.DEFAULT_CLOUDFORMATION_ROLE_ARN);
this.assetPublishingRoleArn = specialize(this.props.assetPublishingRoleArn ?? DefaultStackSynthesizer.DEFAULT_ASSET_PUBLISHING_ROLE_ARN);
// tslint:enable:max-line-length
}
Expand Down Expand Up @@ -259,13 +259,37 @@ export class DefaultStackSynthesizer implements IStackSynthesizer {
const artifactId = this.writeAssetManifest(session);

addStackArtifactToAssembly(session, this.stack, {
assumeRoleArn: this.deployRoleArn,
cloudFormationExecutionRoleArn: this.cloudFormationExecutionRoleArn,
assumeRoleArn: this._deployRoleArn,
cloudFormationExecutionRoleArn: this._cloudFormationExecutionRoleArn,
stackTemplateAssetObjectUrl: templateManifestUrl,
requiresBootstrapStackVersion: 1,
}, [artifactId]);
}

/**
* Returns the ARN of the deploy Role.
*/
public get deployRoleArn(): string {
if (!this._deployRoleArn) {
throw new Error('deployRoleArn getter can only be called after the synthesizer has been bound to a Stack');
}
return this._deployRoleArn;
}

/**
* Returns the ARN of the CFN execution Role.
*/
public get cloudFormationExecutionRoleArn(): string {
if (!this._cloudFormationExecutionRoleArn) {
throw new Error('cloudFormationExecutionRoleArn getter can only be called after the synthesizer has been bound to a Stack');
}
return this._cloudFormationExecutionRoleArn;
}

protected get stack(): Stack | undefined {
return this._stack;
}

/**
* Add the stack's template as one of the manifest assets
*
Expand Down
3 changes: 2 additions & 1 deletion packages/@aws-cdk/core/lib/stack-synthesizers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './types';
export * from './default-synthesizer';
export * from './legacy';
export * from './nested';
export * from './bootstrapless-synthesizer';
export * from './nested';