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 Sep 17, 2018
1 parent 25c9fa0 commit 4830254
Show file tree
Hide file tree
Showing 5 changed files with 277 additions and 15 deletions.
4 changes: 2 additions & 2 deletions packages/@aws-cdk/aws-codepipeline-api/lib/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export interface IStage {
*
* @param action the Action to add to this Stage
*/
_addAction(action: Action): void;
_attachAction(action: Action): void;
}

/**
Expand Down Expand Up @@ -151,7 +151,7 @@ export abstract class Action extends cdk.Construct {
this.runOrder = 1;
this.stage = props.stage;

this.stage._addAction(this);
this.stage._attachAction(this);
}

public validate(): string[] {
Expand Down
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 @@ -17,6 +17,20 @@ const sourceStage = pipeline.addStage('Source');
You can also instantiate the `Stage` Construct directly,
which will add it to the Pipeline provided in its construction properties.

You can insert the new Stage at an arbitrary point in the Pipeline:

```ts
const sourceStage = pipeline.addStage('Source', {
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
Expand Down
73 changes: 67 additions & 6 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, PipelineName, PipelineVersion } from './codepipeline.generated';
import { Stage } from './stage';
import { CommonStageProps, Stage, StagePlacement } from './stage';

/**
* The ARN of a pipeline
Expand Down Expand Up @@ -126,11 +126,13 @@ export class Pipeline extends cdk.Construct implements events.IEventRuleTarget {
* and adding it to this Pipeline.
*
* @param name the name of the newly created Stage
* @param props the optional construction properties of the new Stage
* @returns the newly created Stage
*/
public addStage(name: string): Stage {
public addStage(name: string, props?: CommonStageProps): Stage {
return new Stage(this, name, {
pipeline: this,
...props,
});
}

Expand Down Expand Up @@ -213,6 +215,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 -
Expand All @@ -221,9 +230,12 @@ 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 {
// _addStage should be idempotent, in case a customer ever calls it directly
// ignore unused private method (it's actually used in Stage)
// @ts-ignore
private _attachStage(stage: Stage, placement?: StagePlacement): void {
// _attachStage should be idempotent, in case a customer ever calls it directly
if (this.stages.includes(stage)) {
return;
}
Expand All @@ -232,7 +244,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[] {
Expand All @@ -245,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
56 changes: 52 additions & 4 deletions packages/@aws-cdk/aws-codepipeline/lib/stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,56 @@ 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 properties for the {@link Pipeline#addStage} method.
*/
export interface CommonStageProps {
/**
* 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;
}

/**
* The construction properties for {@link Stage}.
*/
export interface StageProps {
export interface StageProps extends CommonStageProps {
/**
* The Pipeline to add the newly created Stage to.
*/
Expand Down Expand Up @@ -44,7 +90,7 @@ export class Stage extends cdk.Construct implements actions.IStage {
this.pipeline = props.pipeline;
actions.validateName('Stage', name);

this.pipeline._addStage(this);
(this.pipeline as any)._attachStage(this, props.placement);
}

/**
Expand Down Expand Up @@ -91,8 +137,10 @@ export class Stage extends cdk.Construct implements actions.IStage {
return this.pipeline.role;
}

public _addAction(action: actions.Action): void {
// _addAction should be idempotent in case a customer ever calls it directly
// can't make this method private like Pipeline#_attachStage,
// as it comes from the IStage interface
public _attachAction(action: actions.Action): void {
// _attachAction should be idempotent in case a customer ever calls it directly
if (!this._actions.includes(action)) {
this._actions.push(action);
}
Expand Down
145 changes: 142 additions & 3 deletions packages/@aws-cdk/aws-codepipeline/test/test.stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,157 @@ import codepipeline = require('../lib');

export = {
'Pipeline Stages': {
'can also be created by using the Pipeline#addStage method'(test: Test) {
'can be inserted at index 0'(test: Test) {
const stack = new cdk.Stack();
const pipeline = new codepipeline.Pipeline(stack, 'Pipeline');
pipeline.addStage('Stage');

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 = pipeline.addStage('SecondStage');
pipeline.addStage('FirstStage', {
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 = pipeline.addStage('FirstStage');
pipeline.addStage('ThirdStage');
pipeline.addStage('SecondStage', {
placement: {
justAfter: firstStage,
},
});

expect(stack, true).to(haveResource('AWS::CodePipeline::Pipeline', {
"Stages": [
{ "Name": "Stage" },
{ "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(() => {
pipeline.addStage('Stage', {
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 = pipeline.addStage('Stage');

const anotherPipeline = new codepipeline.Pipeline(stack, 'AnotherPipeline');
test.throws(() => {
anotherPipeline.addStage('AnotherStage', {
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 = pipeline.addStage('Stage');

const anotherPipeline = new codepipeline.Pipeline(stack, 'AnotherPipeline');
test.throws(() => {
anotherPipeline.addStage('AnotherStage', {
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 = pipeline.addStage('FirstStage');

test.throws(() => {
pipeline.addStage('SecondStage', {
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();
},
},
};

0 comments on commit 4830254

Please sign in to comment.