Skip to content

Commit

Permalink
aws-cdk-toolkit: Initial external release
Browse files Browse the repository at this point in the history
Co-authored-by: Rico Huijbers <huijbers@amazon.nl>
Co-authored-by: Marcadier-Muller, Romain <rmuller@amazon.lu>
  • Loading branch information
3 people committed May 30, 2018
1 parent 58a45ea commit 4d7d88b
Show file tree
Hide file tree
Showing 31 changed files with 3,335 additions and 0 deletions.
5 changes: 5 additions & 0 deletions packages/aws-cdk-toolkit/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
*.js
*.js.map
*.d.ts
node_modules
dist
2 changes: 2 additions & 0 deletions packages/aws-cdk-toolkit/bin/cdk
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('./cdk.js');
648 changes: 648 additions & 0 deletions packages/aws-cdk-toolkit/bin/cdk.ts

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions packages/aws-cdk-toolkit/lib/api/aws-auth/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { CredentialProviderChain } from 'aws-sdk';

export enum Mode {
ForReading,
ForWriting
}

export interface CredentialProviderSource {
name: string;

/**
* Whether the credential provider is even online
*
* Guaranteed to be called before any of the other functions are called.
*/
isAvailable(): Promise<boolean>;

/**
* Whether the credential provider can provide credentials for the given account.
*/
canProvideCredentials(accountId: string): Promise<boolean>;

/**
* Construct a credential provider for the given account and the given access mode
*
* Guaranteed to be called only if canProvideCredentails() returned true at some point.
*/
getProvider(accountId: string, mode: Mode): Promise<CredentialProviderChain>;
}
12 changes: 12 additions & 0 deletions packages/aws-cdk-toolkit/lib/api/bootstrap-environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { App } from 'aws-cdk';
import { Environment } from 'aws-cdk-cx-api';
import { deployStack, DeployStackResult } from './deploy-stack';
import { SDK } from './util/sdk';
import { ToolkitStack } from './util/toolkit-stack';

export async function bootstrapEnvironment(environment: Environment, aws: SDK, toolkitStackName: string): Promise<DeployStackResult> {
const app = new App();
const stack = new ToolkitStack(app, toolkitStackName, { env: environment });
const synthesizedStack = await app.synthesizeStack(stack.name);
return await deployStack(synthesizedStack, aws);
}
153 changes: 153 additions & 0 deletions packages/aws-cdk-toolkit/lib/api/deploy-stack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { App, Stack } from 'aws-cdk';
import { StackInfo, SynthesizedStack } from 'aws-cdk-cx-api';
import { cloudformation } from 'aws-cdk-resources';
import { CloudFormation } from 'aws-sdk';
import * as colors from 'colors/safe';
import * as crypto from 'crypto';
import * as uuid from 'uuid';
import * as YAML from 'yamljs';
import { debug, error } from '../logging';
import { Mode } from './aws-auth/credentials';
import { ToolkitInfo } from './toolkit-info';
import { describeStack, stackExists, waitForChangeSet, waitForStack } from './util/cloudformation';
import { StackActivityMonitor } from './util/cloudformation/stack-activity-monitor';
import { SDK } from './util/sdk';

type TemplateBodyParameter = {
TemplateBody?: string
TemplateURL?: string
};

export interface DeployStackResult {
readonly noOp: boolean;
readonly outputs: { [name: string]: string };
}

export async function deployStack(stack: SynthesizedStack,
sdk: SDK = new SDK(),
toolkitInfo?: ToolkitInfo,
deployName?: string,
quiet: boolean = false): Promise<DeployStackResult> {
if (!stack.environment) {
throw new Error(`The stack ${stack.name} does not have an environment`);
}

deployName = deployName || stack.name;

const executionId = uuid.v4();

const cfn = await sdk.cloudFormation(stack.environment, Mode.ForWriting);
const bodyParameter = await makeBodyParameter(stack, sdk, toolkitInfo);

if (!await stackExists(cfn, deployName)) {
await createEmptyStack(cfn, deployName, quiet);
} else {
debug('Stack named %s already exists, updating it!', deployName);
}

const changeSetName = `CDK-${executionId}`;
debug('Attempting to create ChangeSet %s on stack %s', changeSetName, deployName);
const changeSet = await cfn.createChangeSet({
StackName: deployName,
ChangeSetName: changeSetName,
Description: `CDK Changeset for execution ${executionId}`,
TemplateBody: bodyParameter.TemplateBody,
TemplateURL: bodyParameter.TemplateURL,
Capabilities: [ 'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM' ]
}).promise();
debug('Initiated creation of changeset: %s; waiting for it to finish creating...', changeSet.Id);
const changeSetDescription = await waitForChangeSet(cfn, deployName, changeSetName);
if (!changeSetDescription || !changeSetDescription.Changes || changeSetDescription.Changes.length === 0) {
debug('No changes are to be performed on %s, assuming success.', deployName);
await cfn.deleteChangeSet({ StackName: deployName, ChangeSetName: changeSetName }).promise();
return { noOp: true, outputs: await getStackOutputs(cfn, deployName) };
}

debug('Initiating execution of changeset %s on stack %s', changeSetName, deployName);
await cfn.executeChangeSet({ StackName: deployName, ChangeSetName: changeSetName }).promise();
const monitor = quiet ? undefined : new StackActivityMonitor(cfn, deployName, stack.metadata, changeSetDescription.Changes.length).start();
debug('Execution of changeset %s on stack %s has started; waiting for the update to complete...', changeSetName, deployName);
await waitForStack(cfn, deployName);
if (monitor) { monitor.stop(); }
debug('Stack %s has completed updating', deployName);
return { noOp: false, outputs: await getStackOutputs(cfn, deployName) };
}

async function getStackOutputs(cfn: CloudFormation, stackName: string): Promise<{ [name: string]: string }> {
const description = await describeStack(cfn, stackName);
const result: { [name: string]: string } = {};
if (description && description.Outputs) {
description.Outputs.forEach(output => {
result[output.OutputKey!] = output.OutputValue!;
});
}
return result;
}

async function createEmptyStack(cfn: CloudFormation, stackName: string, quiet: boolean): Promise<void> {
debug('Creating new empty stack named %s', stackName);
const app = new App();
const stack = new Stack(app, stackName);
stack.templateOptions.description = 'This is an empty stack created by AWS CDK during a deployment attempt';
new cloudformation.WaitConditionHandleResource(stack, 'WaitCondition');
const template = (await app.synthesizeStack(stackName)).template;

const response = await cfn.createStack({ StackName: stackName, TemplateBody: JSON.stringify(template, null, 2) }).promise();
debug('CreateStack response: %j', response);
const monitor = quiet ? undefined : new StackActivityMonitor(cfn, stackName, undefined, 1).start();
await waitForStack(cfn, stackName);
if (monitor) { monitor.stop(); }
}

/**
* Prepares the body parameter for +CreateChangeSet+, putting the generated CloudFormation template in the toolkit-provided
* S3 bucket if present, otherwise using in-line template argument. If no +ToolkitInfo+ is provided and the template is
* larger than 50,200 bytes, an +Error+ will be raised.
*
* @param stack the synthesized stack that provides the CloudFormation template
* @param sdk an AWS SDK to use when interacting with S3
* @param toolkitInfo information about the toolkit stack
*/
async function makeBodyParameter(stack: SynthesizedStack, sdk: SDK, toolkitInfo?: ToolkitInfo): Promise<TemplateBodyParameter> {
const templateJson = YAML.stringify(stack.template, 16, 4);
if (toolkitInfo) {
const hash = crypto.createHash('sha256').update(templateJson).digest('hex');
const key = `cdk/${stack.name}/${hash}.yml`;
const s3 = await sdk.s3(stack.environment!, Mode.ForWriting);
await s3.putObject({
Bucket: toolkitInfo.bucketName,
Key: key,
Body: templateJson,
ContentType: 'application/x-yaml'
}).promise();
debug('Stored template in S3 at s3://%s/%s', toolkitInfo.bucketName, key);
return { TemplateURL: `https://${toolkitInfo.bucketName}.s3.amazonaws.com/${key}` };
} else if (templateJson.length > 51_200) {
error('The template for stack %s is %d bytes long, a CDK Toolkit stack is required for deployment of templates larger than 51,200 bytes. ' +
'A CDK Toolkit stack can be created using %s',
stack.name, templateJson.length, colors.blue(`cdk bootstrap '${stack.environment!.name}'`));
throw new Error(`The template for stack ${stack.name} is larger than 50,200 bytes, and no CDK Toolkit info was provided`);
} else {
return { TemplateBody: templateJson };
}
}

export async function destroyStack(stack: StackInfo, sdk: SDK = new SDK(), deployName?: string, quiet: boolean = false) {
if (!stack.environment) {
throw new Error(`The stack ${stack.name} does not have an environment`);
}

deployName = deployName || stack.name;
const cfn = await sdk.cloudFormation(stack.environment, Mode.ForWriting);
if (!await stackExists(cfn, deployName)) {
return;
}
const monitor = quiet ? undefined : new StackActivityMonitor(cfn, deployName).start();
await cfn.deleteStack({ StackName: deployName }).promise().catch(e => { throw e; });
const destroyedStack = await waitForStack(cfn, deployName, false);
if (monitor) { monitor.stop(); }
if (destroyedStack && destroyedStack.StackStatus !== 'DELETE_COMPLETE') {
throw new Error(`Failed to destroy ${deployName} (current state: ${destroyedStack.StackStatus})!`);
}
return;
}
7 changes: 7 additions & 0 deletions packages/aws-cdk-toolkit/lib/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'source-map-support/register';

export * from './aws-auth/credentials';
export * from './bootstrap-environment';
export * from './deploy-stack';
export * from './toolkit-info';
export * from './util/sdk';
42 changes: 42 additions & 0 deletions packages/aws-cdk-toolkit/lib/api/toolkit-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { App, Output } from 'aws-cdk';
import { Environment } from 'aws-cdk-cx-api';
import { CloudFormation } from 'aws-sdk';
import * as colors from 'colors/safe';
import { debug } from '../logging';
import { Mode } from './aws-auth/credentials';
import { waitForStack } from './util/cloudformation';
import { SDK } from './util/sdk';
import { ToolkitStack } from './util/toolkit-stack';

export interface ToolkitInfo {
bucketName: string
bucketEndpoint: string
}

export async function loadToolkitInfo(environment: Environment, sdk: SDK, stackName: string): Promise<ToolkitInfo | undefined> {
const cfn = await sdk.cloudFormation(environment, Mode.ForReading);
const stack = await waitForStack(cfn, stackName);
if (!stack) {
debug('The environment %s doesn\'t have the CDK toolkit stack (%s) installed. Use %s to setup your environment for use with the toolkit.',
environment.name, stackName, colors.blue(`cdk bootstrap "${environment.name}"`));
return undefined;
}
const stackModel = new ToolkitStack(new App(), 'ToolkitStack', { env: environment });
return {
bucketName: getOutputValue(stack, stackModel.bucketNameOutput),
bucketEndpoint: getOutputValue(stack, stackModel.bucketDomainNameOutput)
};
}

function getOutputValue(stack: CloudFormation.Stack, output: Output): string {
const name = output.name;
let result: string | undefined;
if (stack.Outputs) {
const found = stack.Outputs.find(o => o.OutputKey === name);
result = found && found.OutputValue;
}
if (result === undefined) {
throw new Error(`The CDK toolkit stack (${stack.StackName}) does not have an output named ${name}. Use 'cdk bootstrap' to correct this.`);
}
return result;
}
137 changes: 137 additions & 0 deletions packages/aws-cdk-toolkit/lib/api/util/cloudformation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { CloudFormation } from 'aws-sdk';
import { debug } from '../../logging';
import { StackStatus } from './cloudformation/stack-status';

/**
* Describe a changeset in CloudFormation, regardless of it's current state.
*
* @param cfn a CloudFormation client
* @param stackName the name of the Stack the ChangeSet belongs to
* @param changeSetName the name of the ChangeSet
*
* @returns CloudFormation information about the ChangeSet
*/
async function describeChangeSet(cfn: CloudFormation, stackName: string, changeSetName: string): Promise<CloudFormation.DescribeChangeSetOutput> {
const response = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise();
return response;
}

/**
* Describes a stack in CloudFormation, regardless of it's current state.
*
* @param cfn a CloudFormation client
* @param stackName the name of the stack to be described
*
* @returns +undefined+ if the stack does not exist or is deleted, and the CloudFormation stack description otherwise
*/
export async function describeStack(cfn: CloudFormation, stackName: string): Promise<CloudFormation.Stack | undefined> {
try {
const response = await cfn.describeStacks({ StackName: stackName }).promise();
return response.Stacks && response.Stacks[0];
} catch (e) {
if (e.code === 'ValidationError' && e.message === `Stack with id ${stackName} does not exist`) {
return undefined;
}
throw e;
}
}

/**
* Checks whether a stack exists in CloudFormation.
*
* @param cfn a CloudFormation client
* @param stackName the name of the stack to be checked for
*
* @returns +true+ if the stack exists, regardless of it's current state
*/
export async function stackExists(cfn: CloudFormation, stackName: string): Promise<boolean> {
const description = await describeStack(cfn, stackName);
return description !== undefined;
}

/**
* Waits for a function to return non-+undefined+ before returning.
*
* @param valueProvider a function that will return a value that is not +undefined+ once the wait should be over
* @param timeout the time to wait between two calls to +valueProvider+
*
* @returns the value that was returned by +valueProvider+
*/
async function waitFor<T>(valueProvider: () => Promise<T | null | undefined>, timeout: number = 5000): Promise<T | undefined> {
while (true) {
const result = await valueProvider();
if (result === null) {
return undefined;
} else if (result !== undefined) {
return result;
}
await new Promise(cb => setTimeout(cb, timeout));
}
}

/**
* Waits for a ChangeSet to be available for triggering a StackUpdate.
*
* @param cfn a CloudFormation client
* @param stackName the name of the Stack that the ChangeSet belongs to
* @param changeSetName the name of the ChangeSet
*
* @returns the CloudFormation description of the ChangeSet
*/
// tslint:disable-next-line:max-line-length
export async function waitForChangeSet(cfn: CloudFormation, stackName: string, changeSetName: string): Promise<CloudFormation.DescribeChangeSetOutput | undefined> {
debug('Waiting for changeset %s on stack %s to finish creating...', changeSetName, stackName);
return waitFor(async () => {
const description = await describeChangeSet(cfn, stackName, changeSetName);
// The following doesn't use a switch because tsc will not allow fall-through, UNLESS it is allows
// EVERYWHERE that uses this library directly or indirectly, which is undesirable.
if (description.Status === 'CREATE_PENDING' || description.Status === 'CREATE_IN_PROGRESS') {
debug('Changeset %s on stack %s is still creating', changeSetName, stackName);
return undefined;
} else if (description.Status === 'CREATE_COMPLETE') {
return description;
} else if (description.Status === 'FAILED') {
if (description.StatusReason && description.StatusReason.startsWith('The submitted information didn\'t contain changes.')) {
return description;
}
}
// tslint:disable-next-line:max-line-length
throw new Error(`Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${description.StatusReason || 'no reason provided'}`);
});
}

/**
* Waits for a CloudFormation stack to stabilize in a complete/available state.
*
* @param cfn a CloudFormation client
* @param stackName the name of the stack to wait for
* @param failOnDeletedStack whether to fail if the awaited stack is deleted.
*
* @returns the CloudFormation description of the stabilized stack
*/
export async function waitForStack(cfn: CloudFormation,
stackName: string,
failOnDeletedStack: boolean = true): Promise<CloudFormation.Stack | undefined> {
debug('Waiting for stack %s to finish creating or updating...', stackName);
return waitFor(async () => {
const description = await describeStack(cfn, stackName);
if (!description) {
debug('Stack %s does not exist', stackName);
return null;
}
const status = new StackStatus(description.StackStatus);
if (!status.isStable) {
debug('Stack %s is still not stable (%s)', stackName, status.name);
return undefined;
}
if (status.isCreationFailure) {
throw new Error(`The stack named ${stackName} failed creation, it may need to be manually deleted from the AWS console.`);
} else if (!status.isSuccess) {
throw new Error(`The stack named ${stackName} is in a failed state: ${status.name}`);
} else if (status.isDeleted) {
if (failOnDeletedStack) { throw new Error(`The stack named ${stackName} was deleted`); }
return undefined;
}
return description;
});
}
Loading

0 comments on commit 4d7d88b

Please sign in to comment.