From 1e41ed369fbda27cf34f963d04a92fb630f902f9 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). --- .../@aws-cdk/aws-codepipeline/lib/pipeline.ts | 76 ++++++++- .../@aws-cdk/aws-codepipeline/lib/stage.ts | 43 ++++- .../aws-codepipeline/test/test.stages.ts | 148 ++++++++++++++++++ 3 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 packages/@aws-cdk/aws-codepipeline/test/test.stages.ts diff --git a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts index 754a6781940b1..c3bd545edee3f 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 } from './codepipeline.generated'; -import { Stage } from './stage'; +import { Stage, StagePlacement } from './stage'; /** * The ARN of a pipeline @@ -86,7 +86,7 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget { */ public readonly artifactBucket: s3.BucketRef; - private readonly stages = new Array(); + private readonly _stages = new Array(); private eventsRole?: iam.Role; constructor(parent: cdk.Construct, name: string, props?: PipelineProps) { @@ -210,7 +210,21 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget { ]); } - public _addStage(stage: Stage): void { + /** + * Get a duplicate of this Pipeline's list of Stages. + */ + public get stages(): Stage[] { + return this._stages.slice(); + } + + /** + * Get the number of Stages in this Pipeline. + */ + public get stageCount(): number { + return this._stages.length; + } + + 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; @@ -220,11 +234,59 @@ 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, ${providedPlacementProps} 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[] { - return util.flatMap(this.stages, (stage, i) => { + return util.flatMap(this._stages, (stage, i) => { const onlySourceActionsPermitted = i === 0; return util.flatMap(stage.actions, (action, _) => actions.validateSourceAction(onlySourceActionsPermitted, action.category, action.id, stage.id) @@ -233,7 +295,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 []; @@ -262,6 +324,6 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget { } private renderStages(): cloudformation.PipelineResource.StageDeclarationProperty[] { - return this.stages.map(stage => stage.render()); + 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 b2094f0ff3127..38a7b6e57e651 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 + */ + placed?: 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.placed); } /** 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..b939e2e7b8d5b --- /dev/null +++ b/packages/@aws-cdk/aws-codepipeline/test/test.stages.ts @@ -0,0 +1,148 @@ +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 pipeline = pipelineForTesting(); + + const secondStage = new codepipeline.Stage(pipeline, 'SecondStage', { pipeline }); + const firstStage = new codepipeline.Stage(pipeline, 'FirstStage', { + pipeline, + placed: { + atIndex: 0, + } + }); + + test.equal(pipeline.stages[0].name, firstStage.name); + test.equal(pipeline.stages[1].name, secondStage.name); + + test.done(); + }, + + 'can be inserted before another Stage'(test: Test) { + const pipeline = pipelineForTesting(); + + const secondStage = new codepipeline.Stage(pipeline, 'SecondStage', { pipeline }); + const firstStage = new codepipeline.Stage(pipeline, 'FirstStage', { + pipeline, + placed: { + rightBefore: secondStage, + } + }); + + test.equal(pipeline.stages[0].name, firstStage.name); + test.equal(pipeline.stages[1].name, secondStage.name); + + test.done(); + }, + + 'can be inserted after another Stage'(test: Test) { + const pipeline = pipelineForTesting(); + + const firstStage = new codepipeline.Stage(pipeline, 'FirstStage', { pipeline }); + const thirdStage = new codepipeline.Stage(pipeline, 'ThirdStage', { pipeline }); + const secondStage = new codepipeline.Stage(pipeline, 'SecondStage', { + pipeline, + placed: { + justAfter: firstStage, + } + }); + + test.equal(pipeline.stages[0].name, firstStage.name); + test.equal(pipeline.stages[1].name, secondStage.name); + test.equal(pipeline.stages[2].name, thirdStage.name); + + test.done(); + }, + + 'attempting to insert a Stage at a negative index results in an error'(test: Test) { + const pipeline = pipelineForTesting(); + + test.throws(() => { + new codepipeline.Stage(pipeline, 'Stage', { + pipeline, + placed: { + 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 pipeline = pipelineForTesting(); + + test.throws(() => { + new codepipeline.Stage(pipeline, 'Stage', { + pipeline, + placed: { + atIndex: 1, + } + }); + }, /atIndex/); + + test.done(); + }, + + "attempting to insert a Stage before a Stage that doesn't exist results in an error"(test: Test) { + const pipeline = pipelineForTesting(); + const stage = new codepipeline.Stage(pipeline, 'Stage', { pipeline }); + + const anotherPipeline = pipelineForTesting(); + test.throws(() => { + new codepipeline.Stage(anotherPipeline, 'Stage', { + pipeline: anotherPipeline, + placed: { + 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 pipeline = pipelineForTesting(); + const stage = new codepipeline.Stage(pipeline, 'Stage', { pipeline }); + + const anotherPipeline = pipelineForTesting(); + test.throws(() => { + new codepipeline.Stage(anotherPipeline, 'Stage', { + pipeline: anotherPipeline, + placed: { + justAfter: stage, + } + }); + }, /after/i); + + test.done(); + }, + + "providing more than one placement value results in an error"(test: Test) { + const pipeline = pipelineForTesting(); + const stage = new codepipeline.Stage(pipeline, 'FirstStage', { pipeline }); + + test.throws(() => { + new codepipeline.Stage(pipeline, 'SecondStage', { + pipeline, + placed: { + rightBefore: stage, + justAfter: stage, + } + }); + }); + + test.done(); + }, + }, +}; + +function pipelineForTesting(): codepipeline.Pipeline { + const stack = new cdk.Stack(); + return new codepipeline.Pipeline(stack, 'Pipeline'); +}