Skip to content

Commit

Permalink
refactor(codepipeline): introduce IAction and unify the Action.bind()…
Browse files Browse the repository at this point in the history
… signature (#3012)

This brings the Action `bind()` API in line with our conventions.

It also introduces an `IAction` interface for those who want to work with the low-level Action interface.

The Action class has been moved from the aws-codepipeline to the aws-codepipeline-actions module.

This API is much more flexible, and I show the capabilities by changing the implementation of the `PipelineDeployStackAction` from `@app-delivery`.

BREAKING CHANGE: `app-delivery.PipelineDeployStackAction` is now a `codepipeline.IAction` instead of a construct.
  • Loading branch information
skinny85 authored and Elad Ben-Israel committed Jun 24, 2019
1 parent 758d496 commit 089fc93
Show file tree
Hide file tree
Showing 35 changed files with 831 additions and 791 deletions.
70 changes: 38 additions & 32 deletions packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts
@@ -1,6 +1,7 @@
import cfn = require('@aws-cdk/aws-cloudformation');
import codepipeline = require('@aws-cdk/aws-codepipeline');
import cpactions = require('@aws-cdk/aws-codepipeline-actions');
import events = require('@aws-cdk/aws-events');
import iam = require('@aws-cdk/aws-iam');
import cdk = require('@aws-cdk/core');
import cxapi = require('@aws-cdk/cx-api');
Expand All @@ -11,11 +12,6 @@ export interface PipelineDeployStackActionProps {
*/
readonly stack: cdk.Stack;

/**
* The CodePipeline stage in which to perform the deployment.
*/
readonly stage: codepipeline.IStage;

/**
* The CodePipeline artifact that holds the synthesized app, which is the
* contents of the ``<directory>`` when running ``cdk synth -o <directory>``.
Expand Down Expand Up @@ -86,42 +82,40 @@ export interface PipelineDeployStackActionProps {
}

/**
* A Construct to deploy a stack that is part of a CDK App, using CodePipeline.
* A class to deploy a stack that is part of a CDK App, using CodePipeline.
* This composite Action takes care of preparing and executing a CloudFormation ChangeSet.
*
* It currently does *not* support stacks that make use of ``Asset``s, and
* requires the deployed stack is in the same account and region where the
* CodePipeline is hosted.
*/
export class PipelineDeployStackAction extends cdk.Construct {

export class PipelineDeployStackAction implements codepipeline.IAction {
/**
* The role used by CloudFormation for the deploy action
*/
public readonly deploymentRole: iam.IRole;
private _deploymentRole: iam.IRole;

private readonly stack: cdk.Stack;
private readonly prepareChangeSetAction: cpactions.CloudFormationCreateReplaceChangeSetAction;
private readonly executeChangeSetAction: cpactions.CloudFormationExecuteChangeSetAction;

constructor(scope: cdk.Construct, id: string, props: PipelineDeployStackActionProps) {
super(scope, id);

if (props.stack.environment !== cdk.Stack.of(this).environment) {
// FIXME: Add the necessary to extend to stacks in a different account
throw new Error(`Cross-environment deployment is not supported`);
constructor(props: PipelineDeployStackActionProps) {
this.stack = props.stack;
const assets = this.stack.node.metadata.filter(md => md.type === cxapi.ASSET_METADATA);
if (assets.length > 0) {
// FIXME: Implement the necessary actions to publish assets
throw new Error(`Cannot deploy the stack ${this.stack.stackName} because it references ${assets.length} asset(s)`);
}

const createChangeSetRunOrder = props.createChangeSetRunOrder || 1;
const executeChangeSetRunOrder = props.executeChangeSetRunOrder || (createChangeSetRunOrder + 1);

if (createChangeSetRunOrder >= executeChangeSetRunOrder) {
throw new Error(`createChangeSetRunOrder (${createChangeSetRunOrder}) must be < executeChangeSetRunOrder (${executeChangeSetRunOrder})`);
}

this.stack = props.stack;
const changeSetName = props.changeSetName || 'CDK-CodePipeline-ChangeSet';

const capabilities = cfnCapabilities(props.adminPermissions, props.capabilities);
const changeSetAction = new cpactions.CloudFormationCreateReplaceChangeSetAction({
this.prepareChangeSetAction = new cpactions.CloudFormationCreateReplaceChangeSetAction({
actionName: 'ChangeSet',
changeSetName,
runOrder: createChangeSetRunOrder,
Expand All @@ -131,15 +125,29 @@ export class PipelineDeployStackAction extends cdk.Construct {
deploymentRole: props.role,
capabilities,
});
props.stage.addAction(changeSetAction);
props.stage.addAction(new cpactions.CloudFormationExecuteChangeSetAction({
this.executeChangeSetAction = new cpactions.CloudFormationExecuteChangeSetAction({
actionName: 'Execute',
changeSetName,
runOrder: executeChangeSetRunOrder,
stackName: props.stack.stackName,
}));
stackName: this.stack.stackName,
});
}

public bind(scope: cdk.Construct, stage: codepipeline.IStage, options: codepipeline.ActionBindOptions):
codepipeline.ActionConfig {
if (this.stack.environment !== cdk.Stack.of(scope).environment) {
// FIXME: Add the necessary to extend to stacks in a different account
throw new Error(`Cross-environment deployment is not supported`);
}

this.deploymentRole = changeSetAction.deploymentRole;
stage.addAction(this.prepareChangeSetAction);
this._deploymentRole = this.prepareChangeSetAction.deploymentRole;

return this.executeChangeSetAction.bind(scope, stage, options);
}

public get deploymentRole(): iam.IRole {
return this._deploymentRole;
}

/**
Expand All @@ -155,14 +163,12 @@ export class PipelineDeployStackAction extends cdk.Construct {
this.deploymentRole.addToPolicy(statement);
}

protected validate(): string[] {
const result = super.validate();
const assets = this.stack.node.metadata.filter(md => md.type === cxapi.ASSET_METADATA);
if (assets.length > 0) {
// FIXME: Implement the necessary actions to publish assets
result.push(`Cannot deploy the stack ${this.stack.stackName} because it references ${assets.length} asset(s)`);
}
return result;
public onStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule {
return this.executeChangeSetAction.onStateChange(name, target, options);
}

public get actionProperties(): codepipeline.ActionProperties {
return this.executeChangeSetAction.actionProperties;
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/@aws-cdk/app-delivery/package.json
Expand Up @@ -42,6 +42,7 @@
"@aws-cdk/aws-codebuild": "^0.35.0",
"@aws-cdk/aws-codepipeline": "^0.35.0",
"@aws-cdk/aws-codepipeline-actions": "^0.35.0",
"@aws-cdk/aws-events": "^0.35.0",
"@aws-cdk/aws-iam": "^0.35.0",
"@aws-cdk/core": "^0.35.0",
"@aws-cdk/cx-api": "^0.35.0"
Expand Down Expand Up @@ -75,6 +76,7 @@
"@aws-cdk/aws-codebuild": "^0.35.0",
"@aws-cdk/aws-codepipeline": "^0.35.0",
"@aws-cdk/aws-codepipeline-actions": "^0.35.0",
"@aws-cdk/aws-events": "^0.35.0",
"@aws-cdk/aws-iam": "^0.35.0",
"@aws-cdk/core": "^0.35.0",
"@aws-cdk/cx-api": "^0.35.0"
Expand Down
5 changes: 2 additions & 3 deletions packages/@aws-cdk/app-delivery/test/integ.cicd.ts
Expand Up @@ -27,15 +27,14 @@ pipeline.addStage({
actions: [source],
});
const stage = pipeline.addStage({ stageName: 'Deploy' });
new cicd.PipelineDeployStackAction(stack, 'DeployStack', {
stage,
stage.addAction(new cicd.PipelineDeployStackAction({
stack,
changeSetName: 'CICD-ChangeSet',
createChangeSetRunOrder: 10,
executeChangeSetRunOrder: 999,
input: sourceOutput,
adminPermissions: false,
capabilities: [cfn.CloudFormationCapabilities.NONE],
});
}));

app.synth();
Expand Up @@ -3,10 +3,10 @@ import cfn = require('@aws-cdk/aws-cloudformation');
import codebuild = require('@aws-cdk/aws-codebuild');
import codepipeline = require('@aws-cdk/aws-codepipeline');
import cpactions = require('@aws-cdk/aws-codepipeline-actions');
import events = require('@aws-cdk/aws-events');
import iam = require('@aws-cdk/aws-iam');
import s3 = require('@aws-cdk/aws-s3');
import cdk = require('@aws-cdk/core');
import { ConstructNode } from '@aws-cdk/core';
import cxapi = require('@aws-cdk/cx-api');
import fc = require('fast-check');
import nodeunit = require('nodeunit');
Expand Down Expand Up @@ -34,13 +34,14 @@ export = nodeunit.testCase({
stageName: 'FakeStage',
actions: [fakeAction],
});
new PipelineDeployStackAction(stack, 'Action', {

const deployStage = pipeline.addStage({ stageName: 'DeployStage' });
deployStage.addAction(new PipelineDeployStackAction({
changeSetName: 'ChangeSet',
input: fakeAction.outputArtifact,
stack: new cdk.Stack(app, 'DeployedStack', { env: { account: stackAccount } }),
stage: pipeline.addStage({ stageName: 'DeployStage' }),
adminPermissions: false,
});
}));
}, 'Cross-environment deployment is not supported');
}
)
Expand All @@ -63,15 +64,15 @@ export = nodeunit.testCase({
stageName: 'FakeStage',
actions: [fakeAction],
});
new PipelineDeployStackAction(stack, 'Action', {
const deployStage = pipeline.addStage({ stageName: 'DeployStage' });
deployStage.addAction(new PipelineDeployStackAction({
changeSetName: 'ChangeSet',
createChangeSetRunOrder: createRunOrder,
executeChangeSetRunOrder: executeRunOrder,
input: fakeAction.outputArtifact,
stack: new cdk.Stack(app, 'DeployedStack'),
stage: pipeline.addStage({ stageName: 'DeployStage' }),
adminPermissions: false,
});
}));
}, 'createChangeSetRunOrder must be < executeChangeSetRunOrder');
}
)
Expand Down Expand Up @@ -102,41 +103,36 @@ export = nodeunit.testCase({
const selfUpdateStage4 = pipeline.addStage({ stageName: 'SelfUpdate4' });
const selfUpdateStage5 = pipeline.addStage({ stageName: 'SelfUpdate5' });

new PipelineDeployStackAction(pipelineStack, 'SelfUpdatePipeline', {
stage: selfUpdateStage1,
selfUpdateStage1.addAction(new PipelineDeployStackAction({
stack: pipelineStack,
input: selfUpdatingStack.synthesizedApp,
capabilities: [cfn.CloudFormationCapabilities.NAMED_IAM],
adminPermissions: false,
});
new PipelineDeployStackAction(pipelineStack, 'DeployStack', {
stage: selfUpdateStage2,
}));
selfUpdateStage2.addAction(new PipelineDeployStackAction({
stack: stackWithNoCapability,
input: selfUpdatingStack.synthesizedApp,
capabilities: [cfn.CloudFormationCapabilities.NONE],
adminPermissions: false,
});
new PipelineDeployStackAction(pipelineStack, 'DeployStack2', {
stage: selfUpdateStage3,
}));
selfUpdateStage3.addAction(new PipelineDeployStackAction({
stack: stackWithAnonymousCapability,
input: selfUpdatingStack.synthesizedApp,
capabilities: [cfn.CloudFormationCapabilities.ANONYMOUS_IAM],
adminPermissions: false,
});
new PipelineDeployStackAction(pipelineStack, 'DeployStack3', {
stage: selfUpdateStage4,
}));
selfUpdateStage4.addAction(new PipelineDeployStackAction({
stack: stackWithAutoExpandCapability,
input: selfUpdatingStack.synthesizedApp,
capabilities: [cfn.CloudFormationCapabilities.AUTO_EXPAND],
adminPermissions: false,
});
new PipelineDeployStackAction(pipelineStack, 'DeployStack4', {
stage: selfUpdateStage5,
}));
selfUpdateStage5.addAction(new PipelineDeployStackAction({
stack: stackWithAnonymousAndAutoExpandCapability,
input: selfUpdatingStack.synthesizedApp,
capabilities: [cfn.CloudFormationCapabilities.ANONYMOUS_IAM, cfn.CloudFormationCapabilities.AUTO_EXPAND],
adminPermissions: false,
});
}));
expect(pipelineStack).to(haveResource('AWS::CodePipeline::Pipeline', hasPipelineAction({
Configuration: {
StackName: "TestStack",
Expand Down Expand Up @@ -193,12 +189,11 @@ export = nodeunit.testCase({

const pipeline = selfUpdatingStack.pipeline;
const selfUpdateStage = pipeline.addStage({ stageName: 'SelfUpdate' });
new PipelineDeployStackAction(pipelineStack, 'SelfUpdatePipeline', {
stage: selfUpdateStage,
selfUpdateStage.addAction(new PipelineDeployStackAction({
stack: pipelineStack,
input: selfUpdatingStack.synthesizedApp,
adminPermissions: true,
});
}));
expect(pipelineStack).to(haveResource('AWS::IAM::Policy', {
PolicyDocument: {
Version: '2012-10-17',
Expand Down Expand Up @@ -229,13 +224,13 @@ export = nodeunit.testCase({
});
const pipeline = selfUpdatingStack.pipeline;
const selfUpdateStage = pipeline.addStage({ stageName: 'SelfUpdate' });
const deployAction = new PipelineDeployStackAction(pipelineStack, 'SelfUpdatePipeline', {
stage: selfUpdateStage,
const deployAction = new PipelineDeployStackAction({
stack: pipelineStack,
input: selfUpdatingStack.synthesizedApp,
adminPermissions: false,
role
});
selfUpdateStage.addAction(deployAction);
test.same(deployAction.deploymentRole, role);
test.done();
},
Expand All @@ -252,12 +247,12 @@ export = nodeunit.testCase({
// WHEN //
// this our app/service/infra to deploy
const deployStage = pipeline.addStage({ stageName: 'Deploy' });
const deployAction = new PipelineDeployStackAction(pipelineStack, 'DeployServiceStackA', {
stage: deployStage,
const deployAction = new PipelineDeployStackAction({
stack: emptyStack,
input: selfUpdatingStack.synthesizedApp,
adminPermissions: false,
});
deployStage.addAction(deployAction);
// we might need to add permissions
deployAction.addToDeploymentRolePolicy(new iam.PolicyStatement({
actions: [
Expand Down Expand Up @@ -309,50 +304,48 @@ export = nodeunit.testCase({
fc.integer(1, 5),
(assetCount) => {
const app = new cdk.App();
const stack = new cdk.Stack(app, 'Test');
const pipeline = new codepipeline.Pipeline(stack, 'Pipeline');
const fakeAction = new FakeAction('Fake');
pipeline.addStage({
stageName: 'FakeStage',
actions: [fakeAction],
});

const deployedStack = new cdk.Stack(app, 'DeployedStack');
const deployStage = pipeline.addStage({ stageName: 'DeployStage' });
const action = new PipelineDeployStackAction(stack, 'Action', {
changeSetName: 'ChangeSet',
input: fakeAction.outputArtifact,
stack: deployedStack,
stage: deployStage,
adminPermissions: false,
});
for (let i = 0 ; i < assetCount ; i++) {
deployedStack.node.addMetadata(cxapi.ASSET_METADATA, {});
}
test.deepEqual(ConstructNode.validate(action.node).map(x => x.message),
[`Cannot deploy the stack DeployedStack because it references ${assetCount} asset(s)`]);

test.throws(() => {
new PipelineDeployStackAction({
changeSetName: 'ChangeSet',
input: new codepipeline.Artifact(),
stack: deployedStack,
adminPermissions: false,
});
}, /Cannot deploy the stack DeployedStack because it references/);
}
)
);
test.done();
}
});

class FakeAction extends codepipeline.Action {
class FakeAction implements codepipeline.IAction {
public readonly actionProperties: codepipeline.ActionProperties;
public readonly outputArtifact: codepipeline.Artifact;

constructor(actionName: string) {
super({
this.actionProperties = {
actionName,
artifactBounds: { minInputs: 0, maxInputs: 5, minOutputs: 0, maxOutputs: 5 },
category: codepipeline.ActionCategory.TEST,
provider: 'Test',
});

};
this.outputArtifact = new codepipeline.Artifact('OutputArtifact');
}

protected bind(_info: codepipeline.ActionBind): void {
// do nothing
public bind(_scope: cdk.Construct, _stage: codepipeline.IStage, _options: codepipeline.ActionBindOptions):
codepipeline.ActionConfig {
return {};
}

public onStateChange(_name: string, _target?: events.IRuleTarget, _options?: events.RuleProps): events.Rule {
throw new Error('onStateChange() is not available on FakeAction');
}
}

Expand Down

0 comments on commit 089fc93

Please sign in to comment.