diff --git a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts index ebd84c87f1366..6f751998af367 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts @@ -336,6 +336,18 @@ export class Pipeline extends PipelineBase { return this._stages.slice(); } + /** + * Access one of the pipeline's stages by stage name + */ + public stage(stageName: string): IStage { + for (const stage of this._stages) { + if (stage.stageName === stageName) { + return stage; + } + } + throw new Error(`Pipeline does not contain a stage named '${stageName}'. Available stages: ${this._stages.map(s => s.stageName).join(', ')}`); + } + /** * Returns all of the {@link CrossRegionSupportStack}s that were generated automatically * when dealing with Actions that reside in a different region than the Pipeline itself. diff --git a/packages/@aws-cdk/aws-codepipeline/test/pipeline.test.ts b/packages/@aws-cdk/aws-codepipeline/test/pipeline.test.ts index ab360b80e3038..880c45b1f8a05 100644 --- a/packages/@aws-cdk/aws-codepipeline/test/pipeline.test.ts +++ b/packages/@aws-cdk/aws-codepipeline/test/pipeline.test.ts @@ -1,4 +1,5 @@ -import { expect, haveResourceLike, ResourcePart } from '@aws-cdk/assert'; +import { expect as ourExpect, ResourcePart, arrayWith, objectLike, haveResourceLike } from '@aws-cdk/assert'; +import '@aws-cdk/assert/jest'; import * as iam from '@aws-cdk/aws-iam'; import * as kms from '@aws-cdk/aws-kms'; import * as s3 from '@aws-cdk/aws-s3'; @@ -22,7 +23,7 @@ nodeunitShim({ role, }); - expect(stack, true).to(haveResourceLike('AWS::CodePipeline::Pipeline', { + ourExpect(stack, true).to(haveResourceLike('AWS::CodePipeline::Pipeline', { 'RoleArn': { 'Fn::GetAtt': [ 'Role1ABCC5F0', @@ -109,7 +110,7 @@ nodeunitShim({ ], }); - expect(pipelineStack).to(haveResourceLike('AWS::CodePipeline::Pipeline', { + expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { 'ArtifactStores': [ { 'Region': replicationRegion, @@ -136,9 +137,9 @@ nodeunitShim({ 'Region': pipelineRegion, }, ], - })); + }); - expect(replicationStack).to(haveResourceLike('AWS::KMS::Key', { + expect(replicationStack).toHaveResourceLike('AWS::KMS::Key', { 'KeyPolicy': { 'Statement': [ { @@ -170,7 +171,7 @@ nodeunitShim({ }, ], }, - })); + }); test.done(); }, @@ -204,7 +205,7 @@ nodeunitShim({ ], }); - expect(pipelineStack).to(haveResourceLike('AWS::CodePipeline::Pipeline', { + expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { 'ArtifactStores': [ { 'Region': replicationRegion, @@ -231,12 +232,12 @@ nodeunitShim({ 'Region': pipelineRegion, }, ], - })); + }); - expect(pipeline.crossRegionSupport[replicationRegion].stack).to(haveResourceLike('AWS::KMS::Alias', { + expect(pipeline.crossRegionSupport[replicationRegion].stack).toHaveResourceLike('AWS::KMS::Alias', { 'DeletionPolicy': 'Delete', 'UpdateReplacePolicy': 'Delete', - }, ResourcePart.CompleteDefinition)); + }, ResourcePart.CompleteDefinition); test.done(); }, @@ -276,7 +277,7 @@ nodeunitShim({ ], }); - expect(pipelineStack).to(haveResourceLike('AWS::CodePipeline::Pipeline', { + expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { 'ArtifactStores': [ { 'Region': replicationRegion, @@ -293,7 +294,7 @@ nodeunitShim({ 'Region': pipelineRegion, }, ], - })); + }); test.done(); }, @@ -392,3 +393,42 @@ nodeunitShim({ }, }, }); + +describe('test with shared setup', () => { + let stack: cdk.Stack; + let sourceArtifact: codepipeline.Artifact; + beforeEach(() => { + stack = new cdk.Stack(); + sourceArtifact = new codepipeline.Artifact(); + }); + + test('can add actions to stages after creation', () => { + // GIVEN + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline', { + stages: [ + { + stageName: 'Source', + actions: [new FakeSourceAction({ actionName: 'Fetch', output: sourceArtifact })], + }, + { + stageName: 'Build', + actions: [new FakeBuildAction({ actionName: 'Gcc', input: sourceArtifact })], + }, + ], + }); + + // WHEN + pipeline.stage('Build').addAction(new FakeBuildAction({ actionName: 'debug.com', input: sourceArtifact })); + + // THEN + expect(stack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Stages: arrayWith({ + Name: 'Build', + Actions: [ + objectLike({ Name: 'Gcc' }), + objectLike({ Name: 'debug.com' }), + ], + }), + }); + }); +}); diff --git a/packages/@aws-cdk/pipelines/README.md b/packages/@aws-cdk/pipelines/README.md index 9905468d891f1..8f19d40253bfb 100644 --- a/packages/@aws-cdk/pipelines/README.md +++ b/packages/@aws-cdk/pipelines/README.md @@ -152,6 +152,29 @@ new MyPipelineStack(this, 'PipelineStack', { }); ``` +If you prefer more control over the underlying CodePipeline object, you can +create one yourself, including custom Source and Build stages: + +```ts +const codePipeline = new cp.Pipeline(pipelineStack, 'CodePipeline', { + stages: [ + { + stageName: 'CustomSource', + actions: [...], + }, + { + stageName: 'CustomBuild', + actions: [...], + }, + ], +}); + +const cdkPipeline = new CdkPipeline(this, 'CdkPipeline', { + codePipeline, + cloudAssemblyArtifact, +}); +``` + ## Initial pipeline deployment You provision this pipeline by making sure the target environment has been diff --git a/packages/@aws-cdk/pipelines/lib/pipeline.ts b/packages/@aws-cdk/pipelines/lib/pipeline.ts index 7f9de4aca71c4..6355d836c15c8 100644 --- a/packages/@aws-cdk/pipelines/lib/pipeline.ts +++ b/packages/@aws-cdk/pipelines/lib/pipeline.ts @@ -12,22 +12,44 @@ import { AddStageOptions, AssetPublishingCommand, CdkStage, StackOutput } from ' export interface CdkPipelineProps { /** * The CodePipeline action used to retrieve the CDK app's source + * + * @default - Required unless `codePipeline` is given */ - readonly sourceAction: codepipeline.IAction; + readonly sourceAction?: codepipeline.IAction; /** * The CodePipeline action build and synthesis step of the CDK app + * + * @default - Required unless `codePipeline` or `sourceAction` is given */ - readonly synthAction: codepipeline.IAction; + readonly synthAction?: codepipeline.IAction; /** * The artifact you have defined to be the artifact to hold the cloudAssemblyArtifact for the synth action */ readonly cloudAssemblyArtifact: codepipeline.Artifact; + /** + * Existing CodePipeline to add deployment stages to + * + * Use this if you want more control over the CodePipeline that gets created. + * You can choose to not pass this value, in which case a new CodePipeline is + * created with default settings. + * + * If you pass an existing CodePipeline, it should should have been created + * with `restartExecutionOnUpdate: true`. + * + * [disable-awslint:ref-via-interface] + * + * @default - A new CodePipeline is automatically generated + */ + readonly codePipeline?: codepipeline.Pipeline; + /** * Name of the pipeline * + * Can only be set if `codePipeline` is not set. + * * @default - A name is automatically generated */ readonly pipelineName?: string; @@ -72,28 +94,58 @@ export class CdkPipeline extends Construct { this._cloudAssemblyArtifact = props.cloudAssemblyArtifact; const pipelineStack = Stack.of(this); - this._pipeline = new codepipeline.Pipeline(this, 'Pipeline', { - ...props, - restartExecutionOnUpdate: true, - stages: [ - { - stageName: 'Source', - actions: [props.sourceAction], - }, - { - stageName: 'Build', - actions: [props.synthAction], - }, - { - stageName: 'UpdatePipeline', - actions: [new UpdatePipelineAction(this, 'UpdatePipeline', { - cloudAssemblyInput: this._cloudAssemblyArtifact, - pipelineStackName: pipelineStack.stackName, - cdkCliVersion: props.cdkCliVersion, - projectName: maybeSuffix(props.pipelineName, '-selfupdate'), - })], - }, - ], + if (props.codePipeline) { + if (props.pipelineName) { + throw new Error('Cannot set \'pipelineName\' if an existing CodePipeline is given using \'codePipeline\''); + } + + this._pipeline = props.codePipeline; + } else { + this._pipeline = new codepipeline.Pipeline(this, 'Pipeline', { + pipelineName: props.pipelineName, + restartExecutionOnUpdate: true, + }); + } + + if (props.sourceAction && !props.synthAction) { + // Because of ordering limitations, you can: bring your own Source, bring your own + // Both, or bring your own Nothing. You cannot bring your own Build (which because of the + // current CodePipeline API must go BEFORE what we're adding) and then having us add a + // Source after it. That doesn't make any sense. + throw new Error('When passing a \'sourceAction\' you must also pass a \'synthAction\' (or a \'codePipeline\' that already has both)'); + } + if (!props.sourceAction && (!props.codePipeline || props.codePipeline.stages.length < 1)) { + throw new Error('You must pass a \'sourceAction\' (or a \'codePipeline\' that already has a Source stage)'); + } + if (!props.synthAction && (!props.codePipeline || props.codePipeline.stages.length < 2)) { + // This looks like a weirdly specific requirement, but actually the underlying CodePipeline + // requires that a Pipeline has at least 2 stages. We're just hitching onto upstream + // requirements to do this check. + throw new Error('You must pass a \'synthAction\' (or a \'codePipeline\' that already has a Build stage)'); + } + + if (props.sourceAction) { + this._pipeline.addStage({ + stageName: 'Source', + actions: [props.sourceAction], + }); + } + + if (props.synthAction) { + this._pipeline.addStage({ + stageName: 'Build', + actions: [props.synthAction], + }); + } + + this._pipeline.addStage({ + stageName: 'UpdatePipeline', + actions: [new UpdatePipelineAction(this, 'UpdatePipeline', { + cloudAssemblyInput: this._cloudAssemblyArtifact, + pipelineStackName: pipelineStack.stackName, + cdkCliVersion: props.cdkCliVersion, + projectName: maybeSuffix(props.pipelineName, '-selfupdate'), + })], }); this._assets = new AssetPublishing(this, 'Assets', { @@ -112,10 +164,19 @@ export class CdkPipeline extends Construct { * You can use this to add more Stages to the pipeline, or Actions * to Stages. */ - public get pipeline(): codepipeline.Pipeline { + public get codePipeline(): codepipeline.Pipeline { return this._pipeline; } + /** + * Access one of the pipeline's stages by stage name + * + * You can use this to add more Actions to a stage. + */ + public stage(stageName: string): codepipeline.IStage { + return this._pipeline.stage(stageName); + } + /** * Add pipeline stage that will deploy the given application stage * diff --git a/packages/@aws-cdk/pipelines/test/existing-pipeline.test.ts b/packages/@aws-cdk/pipelines/test/existing-pipeline.test.ts new file mode 100644 index 0000000000000..e9e6ed833e680 --- /dev/null +++ b/packages/@aws-cdk/pipelines/test/existing-pipeline.test.ts @@ -0,0 +1,127 @@ +import { objectLike } from '@aws-cdk/assert'; +import '@aws-cdk/assert/jest'; +import * as cp from '@aws-cdk/aws-codepipeline'; +import { Stack } from '@aws-cdk/core'; +import * as cdkp from '../lib'; +import { PIPELINE_ENV, TestApp, TestGitHubAction } from './testutil'; + +let app: TestApp; +let pipelineStack: Stack; +let sourceArtifact: cp.Artifact; +let cloudAssemblyArtifact: cp.Artifact; +let codePipeline: cp.Pipeline; + +beforeEach(() => { + app = new TestApp(); + pipelineStack = new Stack(app, 'PipelineStack', { env: PIPELINE_ENV }); + sourceArtifact = new cp.Artifact(); + cloudAssemblyArtifact = new cp.Artifact(); +}); + +afterEach(() => { + app.cleanup(); +}); + +describe('with empty existing CodePipeline', () => { + beforeEach(() => { + codePipeline = new cp.Pipeline(pipelineStack, 'CodePipeline'); + }); + + test('both actions are required', () => { + // WHEN + expect(() => { + new cdkp.CdkPipeline(pipelineStack, 'Cdk', { cloudAssemblyArtifact, codePipeline }); + }).toThrow(/You must pass a 'sourceAction'/); + }); + + test('can give both actions', () => { + // WHEN + new cdkp.CdkPipeline(pipelineStack, 'Cdk', { + cloudAssemblyArtifact, + codePipeline, + sourceAction: new TestGitHubAction(sourceArtifact), + synthAction: cdkp.SimpleSynthAction.standardNpmSynth({ sourceArtifact, cloudAssemblyArtifact }), + }); + + // THEN + expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Stages: [ + objectLike({ Name: 'Source' }), + objectLike({ Name: 'Build' }), + objectLike({ Name: 'UpdatePipeline' }), + ], + }); + }); +}); + +describe('with custom Source stage in existing Pipeline', () => { + beforeEach(() => { + codePipeline = new cp.Pipeline(pipelineStack, 'CodePipeline', { + stages: [ + { + stageName: 'CustomSource', + actions: [new TestGitHubAction(sourceArtifact)], + }, + ], + }); + }); + + test('synth action is required', () => { + // WHEN + expect(() => { + new cdkp.CdkPipeline(pipelineStack, 'Cdk', { cloudAssemblyArtifact, codePipeline }); + }).toThrow(/You must pass a 'synthAction'/); + }); + + test('Work with synthAction', () => { + // WHEN + new cdkp.CdkPipeline(pipelineStack, 'Cdk', { + codePipeline, + cloudAssemblyArtifact, + synthAction: cdkp.SimpleSynthAction.standardNpmSynth({ sourceArtifact, cloudAssemblyArtifact }), + }); + + // THEN + expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Stages: [ + objectLike({ Name: 'CustomSource' }), + objectLike({ Name: 'Build' }), + objectLike({ Name: 'UpdatePipeline' }), + ], + }); + }); +}); + +describe('with Source and Build stages in existing Pipeline', () => { + beforeEach(() => { + codePipeline = new cp.Pipeline(pipelineStack, 'CodePipeline', { + stages: [ + { + stageName: 'CustomSource', + actions: [new TestGitHubAction(sourceArtifact)], + }, + { + stageName: 'CustomBuild', + actions: [cdkp.SimpleSynthAction.standardNpmSynth({ sourceArtifact, cloudAssemblyArtifact })], + }, + ], + }); + }); + + test('can supply no actions', () => { + // WHEN + new cdkp.CdkPipeline(pipelineStack, 'Cdk', { + codePipeline, + cloudAssemblyArtifact, + }); + + // THEN + expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Stages: [ + objectLike({ Name: 'CustomSource' }), + objectLike({ Name: 'CustomBuild' }), + objectLike({ Name: 'UpdatePipeline' }), + ], + }); + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/test/pipeline.test.ts b/packages/@aws-cdk/pipelines/test/pipeline.test.ts index 3f216464c55ed..7719620706cc6 100644 --- a/packages/@aws-cdk/pipelines/test/pipeline.test.ts +++ b/packages/@aws-cdk/pipelines/test/pipeline.test.ts @@ -1,6 +1,8 @@ import { anything, arrayWith, deepObjectLike, encodedJson, objectLike, stringLike } from '@aws-cdk/assert'; import '@aws-cdk/assert/jest'; -import { Construct, Stack, Stage, StageProps } from '@aws-cdk/core'; +import * as cp from '@aws-cdk/aws-codepipeline'; +import * as cpa from '@aws-cdk/aws-codepipeline-actions'; +import { Construct, Stack, Stage, StageProps, SecretValue } from '@aws-cdk/core'; import * as cdkp from '../lib'; import { BucketStack, PIPELINE_ENV, stackTemplate, TestApp, TestGitHubNpmPipeline } from './testutil'; @@ -373,6 +375,28 @@ test('can control fix/CLI version used in pipeline selfupdate', () => { }); }); +test('add another action to an existing stage', () => { + // WHEN + pipeline.stage('Source').addAction(new cpa.GitHubSourceAction({ + actionName: 'GitHub2', + oauthToken: SecretValue.plainText('oops'), + output: new cp.Artifact(), + owner: 'OWNER', + repo: 'REPO', + })); + + // THEN + expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Stages: arrayWith({ + Name: 'Source', + Actions: [ + objectLike({ Name: 'GitHub' }), + objectLike({ Name: 'GitHub2' }), + ], + }), + }); +}); + class OneStackApp extends Stage { constructor(scope: Construct, id: string, props?: StageProps) { super(scope, id, props); diff --git a/packages/@aws-cdk/pipelines/test/testutil.ts b/packages/@aws-cdk/pipelines/test/testutil.ts index ca5cb3db0e672..beb6e0180fa87 100644 --- a/packages/@aws-cdk/pipelines/test/testutil.ts +++ b/packages/@aws-cdk/pipelines/test/testutil.ts @@ -40,14 +40,7 @@ export class TestGitHubNpmPipeline extends cdkp.CdkPipeline { const cloudAssemblyArtifact = props?.cloudAssemblyArtifact ?? new codepipeline.Artifact(); super(scope, id, { - sourceAction: new codepipeline_actions.GitHubSourceAction({ - actionName: 'GitHub', - output: sourceArtifact, - oauthToken: SecretValue.plainText('$3kr1t'), - owner: 'test', - repo: 'test', - trigger: codepipeline_actions.GitHubTrigger.POLL, - }), + sourceAction: new TestGitHubAction(sourceArtifact), synthAction: cdkp.SimpleSynthAction.standardNpmSynth({ sourceArtifact, cloudAssemblyArtifact, @@ -61,6 +54,20 @@ export class TestGitHubNpmPipeline extends cdkp.CdkPipeline { } } + +export class TestGitHubAction extends codepipeline_actions.GitHubSourceAction { + constructor(sourceArtifact: codepipeline.Artifact) { + super({ + actionName: 'GitHub', + output: sourceArtifact, + oauthToken: SecretValue.plainText('$3kr1t'), + owner: 'test', + repo: 'test', + trigger: codepipeline_actions.GitHubTrigger.POLL, + }); + } +} + /** * A test stack *