Skip to content

Commit

Permalink
feat(app-delivery) IAM policy for deploy stack
Browse files Browse the repository at this point in the history
 * The changeset and apply changeset can now apply role IAM permissions,
 and CloudFormation Capabilities
 * Document updates for proper build stage configuration
 * Fixes #1151
  • Loading branch information
moofish32 committed Nov 15, 2018
1 parent d397dd7 commit 471008e
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 11 deletions.
32 changes: 28 additions & 4 deletions packages/@aws-cdk/app-delivery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,17 @@ const source = new codepipeline.GitHubSourceAction(pipelineStack, 'GitHub', {
/* ... */
});
const project = new codebuild.PipelineProject(pipelineStack, 'CodeBuild', {
/* ... */
/**
* Choose an environment configuration that meets your use case. For NodeJS
* this might be
* environment: {
* buildImage: codebuild.LinuxBuildImage.UBUNTU_14_04_NODEJS_10_1_0,
* },
*/
});
const synthesizedApp = project.outputArtifact;
const buildStage = pipeline.addStage('build');
const buildAction = project.addBuildToPipeline(buildStage, 'CodeBuild');
const synthesizedApp = buildAction.outputArtifact;

// Optionally, self-update the pipeline stack
const selfUpdateStage = pipeline.addStage('SelfUpdate');
Expand All @@ -69,26 +77,42 @@ const deployStage = pipeline.addStage('Deploy');
const serviceStackA = new MyServiceStackA(app, 'ServiceStackA', { /* ... */ });
const serviceStackB = new MyServiceStackB(app, 'ServiceStackB', { /* ... */ });
// Add actions to deploy the stacks in the deploy stage:
new cicd.PipelineDeployStackAction(pipelineStack, 'DeployServiceStackA', {
const deployServiceAAction = new cicd.PipelineDeployStackAction(pipelineStack, 'DeployServiceStackA', {
stage: deployStage,
stack: serviceStackA,
inputArtifact: synthesizedApp,
});
new cicd.PipelineDeployStackAction(pipelineStack, 'DeployServiceStackB', {

deployServiceAAction.role.addToPolicy(
// new iam.PolicyStatement().
// ... addAction('actions that you need').
// add resource
);

const deployServiceBAction = new cicd.PipelineDeployStackAction(pipelineStack, 'DeployServiceStackB', {
stage: deployStage,
stack: serviceStackB,
inputArtifact: synthesizedApp,
createChangeSetRunOrder: 998,
});
deployServiceBAction.role.addToPolicy(
// new iam.PolicyStatement().
// ... addAction('actions that you need').
// add resource
);
```

#### `buildspec.yml`
The repository can contain a file at the root level named `buildspec.yml`, or
you can in-line the buildspec. Note that `buildspec.yaml` is not compatible.

The `PipelineDeployStackAction` expects it's `inputArtifact` to contain the result of synthesizing a CDK App using the
`cdk synth -o <directory>` command.

For example, a *TypeScript* or *Javascript* CDK App can add the following `buildspec.yml` at the root of the repository
configured in the `Source` stage:

Example contents of `buildspec.yml`.
```yml
version: 0.2
phases:
Expand Down
61 changes: 58 additions & 3 deletions packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

import cfn = require('@aws-cdk/aws-cloudformation');
import codepipeline = require('@aws-cdk/aws-codepipeline-api');
import iam = require('@aws-cdk/aws-iam');
import cdk = require('@aws-cdk/cdk');
import cxapi = require('@aws-cdk/cx-api');

Expand Down Expand Up @@ -41,6 +41,50 @@ export interface PipelineDeployStackActionProps {
* @default ``createChangeSetRunOrder + 1``
*/
executeChangeSetRunOrder?: number;

/**
* IAM role to assume when deploying changes.
*
* If not specified, a fresh role is created. The role is created with zero
* permissions unless `fullPermissions` is true, in which case the role will have
* full permissions.
*
* @default A fresh role with full or no permissions (depending on the value of `fullPermissions`).
*/
role?: iam.Role;

// tslint:disable:max-line-length Because of long URLs in documentation
/**
* Acknowledge certain changes made as part of deployment
*
* For stacks that contain certain resources, explicit acknowledgement that AWS CloudFormation
* might create or update those resources. For example, you must specify CAPABILITY_IAM if your
* stack template contains AWS Identity and Access Management (IAM) resources. For more
* information, see [Acknowledging IAM Resources in AWS CloudFormation Templates] (https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-iam-template.html#using-iam-capabilities).
*
* @default No capabitilities passed, unless `fullPermissions` is true
*/
capabilities?: cfn.CloudFormationCapabilities[];
// tslint:enable:max-line-length

/**
* Whether to grant full permissions to CloudFormation while deploying this template.
*
* Setting this to `true` affects the defaults for `role` and `capabilities`, if you
* don't specify any alternatives.
*
* The default role that will be created for you will have full (i.e., `*`)
* permissions on all resources, and the deployment will have named IAM
* capabilities (i.e., able to create all IAM resources).
*
* This is a shorthand that you can use if you fully trust the templates that
* are deployed in this pipeline. If you want more fine-grained permissions,
* use `addToRolePolicy` and `capabilities` to control what the CloudFormation
* deployment is allowed to do.
*
* @default false
*/
fullPermissions?: boolean;
}

/**
Expand All @@ -52,6 +96,12 @@ export interface PipelineDeployStackActionProps {
* CodePipeline is hosted.
*/
export class PipelineDeployStackAction extends cdk.Construct {

/**
* The role used by CloudFormation for the deploy action
*/
public readonly role: iam.Role;

private readonly stack: cdk.Stack;

constructor(parent: cdk.Construct, id: string, props: PipelineDeployStackActionProps) {
Expand All @@ -69,16 +119,21 @@ export class PipelineDeployStackAction extends cdk.Construct {
throw new Error(`createChangeSetRunOrder (${createChangeSetRunOrder}) must be < executeChangeSetRunOrder (${executeChangeSetRunOrder})`);
}

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

new cfn.PipelineCreateReplaceChangeSetAction(this, 'ChangeSet', {
this.stack = props.stack;

const changeSetAction = new cfn.PipelineCreateReplaceChangeSetAction(this, 'ChangeSet', {
changeSetName,
runOrder: createChangeSetRunOrder,
stackName: props.stack.name,
stage: props.stage,
templatePath: props.inputArtifact.atPath(`${props.stack.name}.template.yaml`),
fullPermissions: props.fullPermissions,
role: props.role,
capabilities: props.capabilities,
});
this.role = changeSetAction.role;

new cfn.PipelineExecuteChangeSetAction(this, 'Execute', {
changeSetName,
Expand Down
10 changes: 7 additions & 3 deletions packages/@aws-cdk/app-delivery/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@
"@aws-cdk/aws-cloudformation": "^0.17.0",
"@aws-cdk/aws-codebuild": "^0.17.0",
"@aws-cdk/aws-codepipeline-api": "^0.17.0",
"@aws-cdk/cdk": "^0.17.0",
"@aws-cdk/cx-api": "^0.17.0"
"@aws-cdk/aws-iam": "^0.17.0",
"@aws-cdk/cx-api": "^0.17.0",
"@aws-cdk/cdk": "^0.17.0"
},
"devDependencies": {
"@aws-cdk/aws-codepipeline": "^0.17.0",
"@aws-cdk/aws-s3": "^0.17.0",
"cdk-build-tools": "^0.17.0",
"cdk-integ-tools": "^0.17.0",
"@aws-cdk/assert": "^0.17.0",
"fast-check": "^1.7.0",
"pkglint": "^0.17.0"
},
Expand All @@ -63,6 +65,8 @@
],
"peerDependencies": {
"@aws-cdk/aws-codepipeline-api": "^0.17.0",
"@aws-cdk/aws-iam": "^0.17.0",
"@aws-cdk/aws-cloudformation": "^0.17.0",
"@aws-cdk/cdk": "^0.17.0"
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import cfn = require('@aws-cdk/aws-cloudformation');
import codebuild = require('@aws-cdk/aws-codebuild');
import code = require('@aws-cdk/aws-codepipeline');
import api = require('@aws-cdk/aws-codepipeline-api');
import iam = require('@aws-cdk/aws-iam');
import s3 = require('@aws-cdk/aws-s3');
import cdk = require('@aws-cdk/cdk');
import cxapi = require('@aws-cdk/cx-api');
import fc = require('fast-check');
import nodeunit = require('nodeunit');

import { countResources, expect, haveResource, isSuperObject } from '@aws-cdk/assert';
import { PipelineDeployStackAction } from '../lib/pipeline-deploy-stack-action';

interface SelfUpdatingPipeline {
synthesizedApp: api.Artifact;
pipeline: code.Pipeline;
}
const accountId = fc.array(fc.integer(0, 9), 12, 12).map(arr => arr.join());

export = nodeunit.testCase({
Expand Down Expand Up @@ -58,7 +68,129 @@ export = nodeunit.testCase({
);
test.done();
},
'users can supply CloudFormation capabilities'(test: nodeunit.Test) {
const pipelineStack = getTestStack();
const selfUpdatingStack = createSelfUpdatingStack(pipelineStack);

const pipeline = selfUpdatingStack.pipeline;
const selfUpdateStage = pipeline.addStage('SelfUpdate');
new PipelineDeployStackAction(pipelineStack, 'SelfUpdatePipeline', {
stage: selfUpdateStage,
stack: pipelineStack,
inputArtifact: selfUpdatingStack.synthesizedApp,
capabilities: [cfn.CloudFormationCapabilities.IAM],
});
expect(pipelineStack).to(haveResource('AWS::CodePipeline::Pipeline', hasPipelineAction({
Configuration: {
StackName: "TestStack",
ActionMode: "CHANGE_SET_REPLACE",
Capabilities: "CAPABILITY_IAM",
}
})));
test.done();
},
'users can supply enable full permissions'(test: nodeunit.Test) {
const pipelineStack = getTestStack();
const selfUpdatingStack = createSelfUpdatingStack(pipelineStack);

const pipeline = selfUpdatingStack.pipeline;
const selfUpdateStage = pipeline.addStage('SelfUpdate');
new PipelineDeployStackAction(pipelineStack, 'SelfUpdatePipeline', {
stage: selfUpdateStage,
stack: pipelineStack,
inputArtifact: selfUpdatingStack.synthesizedApp,
fullPermissions: true,
});
expect(pipelineStack).to(haveResource('AWS::IAM::Policy', {
PolicyDocument: {
Statement: [
{
Action: '*',
Effect: 'Allow',
Resource: '*',
}
],
}
}));
test.done();
},
'users can supply a role for deploy action'(test: nodeunit.Test) {
const pipelineStack = getTestStack();
const selfUpdatingStack = createSelfUpdatingStack(pipelineStack);

const pipeline = selfUpdatingStack.pipeline;
const role = new iam.Role(pipelineStack, 'MyRole', {
assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com'),
});
const selfUpdateStage = pipeline.addStage('SelfUpdate');
const deployAction = new PipelineDeployStackAction(pipelineStack, 'SelfUpdatePipeline', {
stage: selfUpdateStage,
stack: pipelineStack,
inputArtifact: selfUpdatingStack.synthesizedApp,
role
});
test.deepEqual(role.id, deployAction.role.id);
test.done();
},
'users can specify IAM permissions for the deploy action'(test: nodeunit.Test) {
// GIVEN //
const pipelineStack = getTestStack();

// the fake stack to deploy
const emptyStack = getTestStack();

const selfUpdatingStack = createSelfUpdatingStack(pipelineStack);
const pipeline = selfUpdatingStack.pipeline;

// WHEN //
// this our app/service/infra to deploy
const deployStage = pipeline.addStage('Deploy');
const deployAction = new PipelineDeployStackAction(pipelineStack, 'DeployServiceStackA', {
stage: deployStage,
stack: emptyStack,
inputArtifact: selfUpdatingStack.synthesizedApp,
});
// we might need to add permissions
deployAction.role.addToPolicy( new iam.PolicyStatement().
addAction('ec2:AuthorizeSecurityGroupEgress').
addAction('ec2:AuthorizeSecurityGroupIngress').
addAction('ec2:DeleteSecurityGroup').
addAction('ec2:DescribeSecurityGroups').
addAction('ec2:CreateSecurityGroup').
addAction('ec2:RevokeSecurityGroupEgress').
addAction('ec2:RevokeSecurityGroupIngress').
addAllResources());

// THEN //
// there should be 3 policies 1. CodePipeline, 2. Codebuild, 3.
// ChangeSetDeploy Action
expect(pipelineStack).to(countResources('AWS::IAM::Policy', 3));
expect(pipelineStack).to(haveResource('AWS::IAM::Policy', {
PolicyDocument: {
Statement: [
{
Action: [
'ec2:AuthorizeSecurityGroupEgress',
'ec2:AuthorizeSecurityGroupIngress',
'ec2:DeleteSecurityGroup',
'ec2:DescribeSecurityGroups',
'ec2:CreateSecurityGroup',
'ec2:RevokeSecurityGroupEgress',
'ec2:RevokeSecurityGroupIngress'
],
Effect: 'Allow',
Resource: '*',
},
],
},
Roles: [
{
Ref: 'DeployServiceStackAChangeSetRoleA1245536',
},
],
}));
test.done();
},
'rejects stacks with assets'(test: nodeunit.Test) {
fc.assert(
fc.property(
Expand All @@ -79,7 +211,7 @@ export = nodeunit.testCase({
deployedStack.addMetadata(cxapi.ASSET_METADATA, {});
}
test.deepEqual(action.validate(),
[`Cannot deploy the stack DeployedStack because it references ${assetCount} asset(s)`]);
[`Cannot deploy the stack DeployedStack because it references ${assetCount} asset(s)`]);
}
)
);
Expand All @@ -101,3 +233,40 @@ class FakeAction extends api.Action {
this.outputArtifact = new api.Artifact(this, 'OutputArtifact');
}
}

function getTestStack(): cdk.Stack {
return new cdk.Stack(undefined, 'TestStack', { env: { account: '123456789012', region: 'us-east-1' } });
}

function createSelfUpdatingStack(pipelineStack: cdk.Stack): SelfUpdatingPipeline {
const pipeline = new code.Pipeline(pipelineStack, 'CodePipeline', {
restartExecutionOnUpdate: true,
});

// simple source
const bucket = s3.Bucket.import( pipeline, 'PatternBucket', { bucketArn: 'arn:aws:s3:::totally-fake-bucket' });
new s3.PipelineSourceAction(pipeline, 'S3Source', {
bucket,
bucketKey: 'the-great-key',
stage: pipeline.addStage('source'),
});

const project = new codebuild.PipelineProject(pipelineStack, 'CodeBuild');
const buildStage = pipeline.addStage('build');
const buildAction = project.addBuildToPipeline(buildStage, 'CodeBuild');
const synthesizedApp = buildAction.outputArtifact;
return {synthesizedApp, pipeline};
}

function hasPipelineAction(expectedAction: any): (props: any) => boolean {
return (props: any) => {
for (const stage of props.Stages) {
for (const action of stage.Actions) {
if (isSuperObject(action, expectedAction)) {
return true;
}
}
}
return false;
};
}

0 comments on commit 471008e

Please sign in to comment.