Skip to content

Commit

Permalink
feat(aws-codepipeline): Make the Stage insertion API in CodePipeline …
Browse files Browse the repository at this point in the history
…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).
  • Loading branch information
skinny85 committed Aug 15, 2018
1 parent 0b92202 commit b257d46
Show file tree
Hide file tree
Showing 4 changed files with 280 additions and 8 deletions.
14 changes: 14 additions & 0 deletions packages/@aws-cdk/aws-codepipeline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ 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,
placed: {
// note: you can only specify one of these properties
rightBefore: anotherStage,
justAfter: anotherStage,
atIndex: 3, // pipeline.stageCount returns the number of Stages currently in the Pipeline
}
})
```

Add an Action to a Stage:

```ts
Expand Down
78 changes: 71 additions & 7 deletions packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,7 +86,7 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget {
*/
public readonly artifactBucket: s3.BucketRef;

private readonly stages = new Array<Stage>();
private readonly _stages = new Array<Stage>();
private eventsRole?: iam.Role;

constructor(parent: cdk.Construct, name: string, props?: PipelineProps) {
Expand Down Expand Up @@ -210,6 +210,20 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget {
]);
}

/**
* 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;
}

/**
* Adds a Stage to this Pipeline.
* This is an internal operation -
Expand All @@ -218,8 +232,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;
Expand All @@ -229,11 +244,60 @@ 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[] {
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)
Expand All @@ -242,7 +306,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 [];
Expand Down Expand Up @@ -271,6 +335,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());
}
}
43 changes: 42 additions & 1 deletion packages/@aws-cdk/aws-codepipeline/lib/stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*/
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down
153 changes: 153 additions & 0 deletions packages/@aws-cdk/aws-codepipeline/test/test.stages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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,
}
});
// 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();
},
},
};

function pipelineForTesting(): codepipeline.Pipeline {
const stack = new cdk.Stack();
return new codepipeline.Pipeline(stack, 'Pipeline');
}

0 comments on commit b257d46

Please sign in to comment.