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. (aws#460)

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 authored Sep 18, 2018
1 parent e6cc189 commit d182818
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 d182818

Please sign in to comment.