From 92ecac4e13de0f0461c5d80290adb0111de407ae Mon Sep 17 00:00:00 2001 From: Adam Ruka Date: Thu, 26 Jul 2018 17:43:58 -0700 Subject: [PATCH] feat(aws-codepipeline): Make the Stage insertion API in CodePipeline more flexible. This commit allows clients of CodePipeline to create new Stages placed at an arbitrary index in the Pipeline, or before/after a given Stage (instead of only appending new Stages at the end). --- packages/@aws-cdk/aws-codepipeline/README.md | 15 ++ .../@aws-cdk/aws-codepipeline/lib/pipeline.ts | 65 ++++++- .../@aws-cdk/aws-codepipeline/lib/stage.ts | 43 ++++- .../aws-codepipeline/test/test.stages.ts | 169 ++++++++++++++++++ 4 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 packages/@aws-cdk/aws-codepipeline/test/test.stages.ts diff --git a/packages/@aws-cdk/aws-codepipeline/README.md b/packages/@aws-cdk/aws-codepipeline/README.md index 9cbb2d48b3dad..fb9f41e3d277b 100644 --- a/packages/@aws-cdk/aws-codepipeline/README.md +++ b/packages/@aws-cdk/aws-codepipeline/README.md @@ -16,6 +16,21 @@ const sourceStage = new Stage(this, 'Source', { }); ``` +You can insert the new Stage at an arbitrary point in the Pipeline: + +```ts +const sourceStage = new Stage(this, 'Source', { + pipeline, + placement: { + // note: you can only specify one of the below properties + rightBefore: anotherStage, + justAfter: anotherStage, + atIndex: 3, // indexing starts at 0 + // pipeline.stageCount returns the number of Stages currently in the Pipeline + } +}) +``` + Add an Action to a Stage: ```ts diff --git a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts index ffe8b6f01c214..dbf6cb8f3139f 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts @@ -5,7 +5,7 @@ import s3 = require('@aws-cdk/aws-s3'); import cdk = require('@aws-cdk/cdk'); import util = require('@aws-cdk/util'); import { cloudformation, PipelineName, PipelineVersion } from './codepipeline.generated'; -import { Stage } from './stage'; +import { Stage, StagePlacement } from './stage'; /** * The ARN of a pipeline @@ -200,6 +200,13 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget { ]); } + /** + * Get the number of Stages in this Pipeline. + */ + public get stageCount(): number { + return this.stages.length; + } + /** * Adds a Stage to this Pipeline. * This is an internal operation - @@ -208,8 +215,9 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget { * so there is never a need to call this method explicitly. * * @param stage the newly created Stage to add to this Pipeline + * @param placement an optional specification of where to place the newly added Stage in the Pipeline */ - public _addStage(stage: Stage): void { + public _addStage(stage: Stage, placement?: StagePlacement): void { // _addStage should be idempotent, in case a customer ever calls it directly if (this.stages.includes(stage)) { return; @@ -219,7 +227,56 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget { throw new Error(`A stage with name '${stage.name}' already exists`); } - this.stages.push(stage); + const index = placement + ? this.calculateInsertIndexFromPlacement(placement) + : this.stageCount; + + this.stages.splice(index, 0, stage); + } + + private calculateInsertIndexFromPlacement(placement: StagePlacement): number { + // check if at most one placement property was provided + const providedPlacementProps = ['rightBefore', 'justAfter', 'atIndex'] + .filter((prop) => (placement as any)[prop] !== undefined); + if (providedPlacementProps.length > 1) { + throw new Error("Error adding Stage to the Pipeline: " + + 'you can only provide at most one placement property, but ' + + `'${providedPlacementProps.join(', ')}' were given`); + } + + if (placement.rightBefore !== undefined) { + const targetIndex = this.findStageIndex(placement.rightBefore); + if (targetIndex === -1) { + throw new Error("Error adding Stage to the Pipeline: " + + `the requested Stage to add it before, '${placement.rightBefore.name}', was not found`); + } + return targetIndex; + } + + if (placement.justAfter !== undefined) { + const targetIndex = this.findStageIndex(placement.justAfter); + if (targetIndex === -1) { + throw new Error("Error adding Stage to the Pipeline: " + + `the requested Stage to add it after, '${placement.justAfter.name}', was not found`); + } + return targetIndex + 1; + } + + if (placement.atIndex !== undefined) { + const index = placement.atIndex; + if (index < 0 || index > this.stageCount) { + throw new Error("Error adding Stage to the Pipeline: " + + `{ placed: atIndex } should be between 0 and the number of stages in the Pipeline (${this.stageCount}), ` + + ` got: ${index}`); + } + return index; + } + + return this.stageCount; + } + + private findStageIndex(targetStage: Stage) { + return this.stages.findIndex((stage: Stage) => stage === targetStage); } private validateSourceActionLocations(): string[] { @@ -232,7 +289,7 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget { } private validateHasStages(): string[] { - if (this.stages.length < 2) { + if (this.stageCount < 2) { return ['Pipeline must have at least two stages']; } return []; diff --git a/packages/@aws-cdk/aws-codepipeline/lib/stage.ts b/packages/@aws-cdk/aws-codepipeline/lib/stage.ts index b2094f0ff3127..ac0d25fe33d50 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/stage.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/stage.ts @@ -5,6 +5,39 @@ import cdk = require('@aws-cdk/cdk'); import { cloudformation } from './codepipeline.generated'; import { Pipeline } from './pipeline'; +/** + * Allows you to control where to place a new Stage when it's added to the Pipeline. + * Note that you can provide only one of the below properties - + * specifying more than one will result in a validation error. + * + * @see #rightBefore + * @see #justAfter + * @see #atIndex + */ +export interface StagePlacement { + /** + * Inserts the new Stage as a parent of the given Stage + * (changing its current parent Stage, if it had one). + */ + readonly rightBefore?: Stage; + + /** + * Inserts the new Stage as a child of the given Stage + * (changing its current child Stage, if it had one). + */ + readonly justAfter?: Stage; + + /** + * Inserts the new Stage at the given index in the Pipeline, + * moving the Stage currently at that index, + * and any subsequent ones, one index down. + * Indexing starts at 0. + * The maximum allowed value is {@link Pipeline#stageCount}, + * which will insert the new Stage at the end of the Pipeline. + */ + readonly atIndex?: number; +} + /** * The construction properties for {@link Stage}. */ @@ -13,6 +46,14 @@ export interface StageProps { * The Pipeline to add the newly created Stage to. */ pipeline: Pipeline; + + /** + * Allows specifying where should the newly created {@link Stage} + * be placed in the Pipeline. + * + * @default the stage is added at the end of the Pipeline + */ + placement?: StagePlacement; } /** @@ -44,7 +85,7 @@ export class Stage extends cdk.Construct implements actions.IStage { this.pipeline = props.pipeline; actions.validateName('Stage', name); - this.pipeline._addStage(this); + this.pipeline._addStage(this, props.placement); } /** diff --git a/packages/@aws-cdk/aws-codepipeline/test/test.stages.ts b/packages/@aws-cdk/aws-codepipeline/test/test.stages.ts new file mode 100644 index 0000000000000..f95e32a8e1cdb --- /dev/null +++ b/packages/@aws-cdk/aws-codepipeline/test/test.stages.ts @@ -0,0 +1,169 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import codepipeline = require('../lib'); + +// tslint:disable:object-literal-key-quotes + +export = { + 'Pipeline Stages': { + 'can be inserted at index 0'(test: Test) { + const stack = new cdk.Stack(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline'); + + new codepipeline.Stage(stack, 'SecondStage', { pipeline }); + new codepipeline.Stage(stack, 'FirstStage', { + pipeline, + placement: { + atIndex: 0, + }, + }); + + expect(stack, true).to(haveResource('AWS::CodePipeline::Pipeline', { + "Stages": [ + { "Name": "FirstStage" }, + { "Name": "SecondStage" }, + ], + })); + + test.done(); + }, + + 'can be inserted before another Stage'(test: Test) { + const stack = new cdk.Stack(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline'); + + const secondStage = new codepipeline.Stage(stack, 'SecondStage', { pipeline }); + new codepipeline.Stage(stack, 'FirstStage', { + pipeline, + placement: { + rightBefore: secondStage, + }, + }); + + expect(stack, true).to(haveResource('AWS::CodePipeline::Pipeline', { + "Stages": [ + { "Name": "FirstStage" }, + { "Name": "SecondStage" }, + ], + })); + + test.done(); + }, + + 'can be inserted after another Stage'(test: Test) { + const stack = new cdk.Stack(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline'); + + const firstStage = new codepipeline.Stage(stack, 'FirstStage', { pipeline }); + new codepipeline.Stage(stack, 'ThirdStage', { pipeline }); + new codepipeline.Stage(stack, 'SecondStage', { + pipeline, + placement: { + justAfter: firstStage, + }, + }); + + expect(stack, true).to(haveResource('AWS::CodePipeline::Pipeline', { + "Stages": [ + { "Name": "FirstStage" }, + { "Name": "SecondStage" }, + { "Name": "ThirdStage" }, + ], + })); + + test.done(); + }, + + 'attempting to insert a Stage at a negative index results in an error'(test: Test) { + const stack = new cdk.Stack(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline'); + + test.throws(() => { + new codepipeline.Stage(stack, 'Stage', { + pipeline, + placement: { + atIndex: -1, + }, + }); + }, /atIndex/); + + test.done(); + }, + + 'attempting to insert a Stage at an index larger than the current number of Stages results in an error'(test: Test) { + const stack = new cdk.Stack(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline'); + + test.throws(() => { + new codepipeline.Stage(stack, 'Stage', { + pipeline, + placement: { + atIndex: 1, + }, + }); + }, /atIndex/); + + test.done(); + }, + + "attempting to insert a Stage before a Stage that doesn't exist results in an error"(test: Test) { + const stack = new cdk.Stack(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline'); + const stage = new codepipeline.Stage(stack, 'Stage', { pipeline }); + + const anotherPipeline = new codepipeline.Pipeline(stack, 'AnotherPipeline'); + test.throws(() => { + new codepipeline.Stage(stack, 'AnotherStage', { + pipeline: anotherPipeline, + placement: { + rightBefore: stage, + }, + }); + }, /before/i); + + test.done(); + }, + + "attempting to insert a Stage after a Stage that doesn't exist results in an error"(test: Test) { + const stack = new cdk.Stack(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline'); + const stage = new codepipeline.Stage(stack, 'Stage', { pipeline }); + + const anotherPipeline = new codepipeline.Pipeline(stack, 'AnotherPipeline'); + test.throws(() => { + new codepipeline.Stage(stack, 'AnotherStage', { + pipeline: anotherPipeline, + placement: { + justAfter: stage, + }, + }); + }, /after/i); + + test.done(); + }, + + "providing more than one placement value results in an error"(test: Test) { + const stack = new cdk.Stack(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline'); + const stage = new codepipeline.Stage(stack, 'FirstStage', { pipeline }); + + test.throws(() => { + new codepipeline.Stage(stack, 'SecondStage', { + pipeline, + placement: { + rightBefore: stage, + justAfter: stage, + }, + }); + // incredibly, an arrow function below causes nodeunit to crap out with: + // "TypeError: Function has non-object prototype 'undefined' in instanceof check" + // tslint:disable-next-line:only-arrow-functions + }, function(e: any) { + return /rightBefore/.test(e) && /justAfter/.test(e); + }); + + test.done(); + }, + }, +};