Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(toolkit): support diff on multiple stacks #1855

Merged
merged 5 commits into from
Feb 28, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/@aws-cdk/cloudformation-diff/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { SecurityGroupChanges } from './network/security-group-changes';
// tslint:disable-next-line:no-var-requires
const { structuredPatch } = require('diff');

export interface FormatStream extends NodeJS.WritableStream {
columns?: number;
}

/**
* Renders template differences to the process' console.
*
Expand All @@ -20,7 +24,7 @@ const { structuredPatch } = require('diff');
* case there is no aws:cdk:path metadata in the template.
* @param context the number of context lines to use in arbitrary JSON diff (defaults to 3).
*/
export function formatDifferences(stream: NodeJS.WriteStream,
export function formatDifferences(stream: FormatStream,
templateDiff: TemplateDiff,
logicalToPathMap: { [logicalId: string]: string } = { },
context: number = 3) {
Expand Down Expand Up @@ -72,7 +76,7 @@ const UPDATE = colors.yellow('[~]');
const REMOVAL = colors.red('[-]');

class Formatter {
constructor(private readonly stream: NodeJS.WriteStream,
constructor(private readonly stream: FormatStream,
private readonly logicalToPathMap: { [logicalId: string]: string },
diff?: TemplateDiff,
private readonly context: number = 3) {
Expand Down
100 changes: 33 additions & 67 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
#!/usr/bin/env node
import 'source-map-support/register';

import cxapi = require('@aws-cdk/cx-api');
import colors = require('colors/safe');
import fs = require('fs-extra');
import util = require('util');
import yargs = require('yargs');

import { bootstrapEnvironment, deployStack, destroyStack, loadToolkitInfo, Mode, SDK } from '../lib';
import { bootstrapEnvironment, deployStack, destroyStack, loadToolkitInfo, SDK } from '../lib';
import { environmentsFromDescriptors, globEnvironmentsFromStacks } from '../lib/api/cxapp/environments';
import { execProgram } from '../lib/api/cxapp/exec';
import { AppStacks, ExtendedStackSelection, listStackNames } from '../lib/api/cxapp/stacks';
import { CfnProvisioner } from '../lib/api/provisioner';
import { leftPad } from '../lib/api/util/string-manipulation';
import { printSecurityDiff, printStackDiff, RequireApproval } from '../lib/diff';
import { CdkToolkit } from '../lib/cdk-toolkit';
import { printSecurityDiff, RequireApproval } from '../lib/diff';
import { availableInitLanguages, cliInit, printAvailableTemplates } from '../lib/init';
import { interactive } from '../lib/interactive';
import { data, debug, error, highlight, print, setVerbose, success } from '../lib/logging';
import { PluginHost } from '../lib/plugin';
import { parseRenames } from '../lib/renames';
import { deserializeStructure, serializeStructure } from '../lib/serialize';
import { serializeStructure } from '../lib/serialize';
import { Configuration, Settings } from '../lib/settings';
import { VERSION } from '../lib/version';

Expand Down Expand Up @@ -66,7 +67,8 @@ async function parseCommandLineArguments() {
.command('destroy [STACKS..]', 'Destroy the stack(s) named STACKS', yargs => yargs
.option('exclusively', { type: 'boolean', alias: 'x', desc: 'only deploy requested stacks, don\'t include dependees' })
.option('force', { type: 'boolean', alias: 'f', desc: 'Do not ask for confirmation before destroying the stacks' }))
.command('diff [STACK]', 'Compares the specified stack with the deployed stack or a local template file, and returns with status 1 if any difference is found', yargs => yargs
.command('diff [STACKS..]', 'Compares the specified stack with the deployed stack or a local template file, and returns with status 1 if any difference is found', yargs => yargs
.option('exclusively', { type: 'boolean', alias: 'e', desc: 'only deploy requested stacks, don\'t include dependencies' })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"only diff requested stacks"

rix0rrr marked this conversation as resolved.
Show resolved Hide resolved
.option('context-lines', { type: 'number', desc: 'number of context lines to include in arbitrary JSON diff rendering', default: 3 })
.option('template', { type: 'string', desc: 'the path to the CloudFormation template to compare with' })
.option('strict', { type: 'boolean', desc: 'do not filter out AWS::CDK::Metadata resources', default: false }))
Expand Down Expand Up @@ -108,13 +110,17 @@ async function initCommandLine() {
await configuration.load();
configuration.logDefaults();

const provisioner = new CfnProvisioner({ aws });

const appStacks = new AppStacks({
verbose: argv.trace || argv.verbose,
ignoreErrors: argv.ignoreErrors,
strict: argv.strict,
configuration, aws, synthesizer: execProgram });

const renames = parseRenames(argv.rename);
configuration,
aws,
synthesizer: execProgram,
renames: parseRenames(argv.rename)
});

/** Function to load plug-ins, using configurations additively. */
function loadPlugins(...settings: Settings[]) {
Expand Down Expand Up @@ -166,13 +172,21 @@ async function initCommandLine() {
args.STACKS = args.STACKS || [];
args.ENVIRONMENTS = args.ENVIRONMENTS || [];

const cli = new CdkToolkit({ appStacks, provisioner });

switch (command) {
case 'ls':
case 'list':
return await cliList({ long: args.long });

case 'diff':
return await diffStack(await findStack(args.STACK), args.template, args.strict, args.contextLines);
return await cli.diff({
stackNames: args.STACKS,
exclusively: args.exclusively,
templatePath: args.template,
strict: args.strict,
contextLines: args.contextLines
});

case 'bootstrap':
return await cliBootstrap(args.ENVIRONMENTS, toolkitStackName, args.roleArn);
Expand Down Expand Up @@ -259,7 +273,6 @@ async function initCommandLine() {
const autoSelectDependencies = !exclusively && outputDir !== undefined;

const stacks = await appStacks.selectStacks(stackNames, autoSelectDependencies ? ExtendedStackSelection.Upstream : ExtendedStackSelection.None);
renames.validateSelectedStacks(stacks);

if (doInteractive) {
if (stacks.length !== 1) {
Expand Down Expand Up @@ -295,9 +308,8 @@ async function initCommandLine() {

let i = 0;
for (const stack of stacks) {
const finalName = renames.finalName(stack.name);
const prefix = numbered ? leftPad(`${i}`, 3, '0') + '.' : '';
const fileName = `${outputDir}/${prefix}${finalName}.template.${json ? 'json' : 'yaml'}`;
const fileName = `${outputDir}/${prefix}${stack.name}.template.${json ? 'json' : 'yaml'}`;
highlight(fileName);
await fs.writeFile(fileName, toJsonOrYaml(stack.template));
i++;
Expand Down Expand Up @@ -338,7 +350,6 @@ async function initCommandLine() {
if (requireApproval === undefined) { requireApproval = RequireApproval.Broadening; }

const stacks = await appStacks.selectStacks(stackNames, exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream);
renames.validateSelectedStacks(stacks);

for (const stack of stacks) {
if (stacks.length !== 1) { highlight(stack.name); }
Expand All @@ -347,10 +358,9 @@ async function initCommandLine() {
throw new Error(`Stack ${stack.name} does not define an environment, and AWS credentials could not be obtained from standard locations or no region was configured.`);
}
const toolkitInfo = await loadToolkitInfo(stack.environment, aws, toolkitStackName);
const deployName = renames.finalName(stack.name);

if (requireApproval !== RequireApproval.Never) {
const currentTemplate = await readCurrentTemplate(stack);
const currentTemplate = await provisioner.readCurrentTemplate(stack);
if (printSecurityDiff(currentTemplate, stack, requireApproval)) {

// only talk to user if we STDIN is a terminal (otherwise, fail)
Expand All @@ -365,14 +375,14 @@ async function initCommandLine() {
}
}

if (deployName !== stack.name) {
print('%s: deploying... (was %s)', colors.bold(deployName), colors.bold(stack.name));
if (stack.name !== stack.originalName) {
print('%s: deploying... (was %s)', colors.bold(stack.name), colors.bold(stack.originalName));
} else {
print('%s: deploying...', colors.bold(stack.name));
}

try {
const result = await deployStack({ stack, sdk: aws, toolkitInfo, deployName, roleArn, ci });
const result = await deployStack({ stack, sdk: aws, toolkitInfo, deployName: stack.name, roleArn, ci });
const message = result.noOp
? ` ✅ %s (no changes)`
: ` ✅ %s`;
Expand All @@ -385,7 +395,7 @@ async function initCommandLine() {

for (const name of Object.keys(result.outputs)) {
const value = result.outputs[name];
print('%s.%s = %s', colors.cyan(deployName), colors.cyan(name), colors.underline(colors.cyan(value)));
print('%s.%s = %s', colors.cyan(stack.name), colors.cyan(name), colors.underline(colors.cyan(value)));
}

print('\nStack ARN:');
Expand All @@ -404,8 +414,6 @@ async function initCommandLine() {
// The stacks will have been ordered for deployment, so reverse them for deletion.
stacks.reverse();

renames.validateSelectedStacks(stacks);

if (!force) {
// tslint:disable-next-line:max-line-length
const confirmed = await confirm(`Are you sure you want to delete: ${colors.blue(stacks.map(s => s.name).join(', '))} (y/n)?`);
Expand All @@ -415,59 +423,17 @@ async function initCommandLine() {
}

for (const stack of stacks) {
const deployName = renames.finalName(stack.name);

success('%s: destroying...', colors.blue(deployName));
success('%s: destroying...', colors.blue(stack.name));
try {
await destroyStack({ stack, sdk: aws, deployName, roleArn });
success('\n ✅ %s: destroyed', colors.blue(deployName));
await destroyStack({ stack, sdk: aws, deployName: stack.name, roleArn });
success('\n ✅ %s: destroyed', colors.blue(stack.name));
} catch (e) {
error('\n ❌ %s: destroy failed', colors.blue(deployName), e);
error('\n ❌ %s: destroy failed', colors.blue(stack.name), e);
throw e;
}
}
}

async function diffStack(stackName: string, templatePath: string | undefined, strict: boolean, context: number): Promise<number> {
const stack = await appStacks.synthesizeStack(stackName);
const currentTemplate = await readCurrentTemplate(stack, templatePath);
if (printStackDiff(currentTemplate, stack, strict, context) === 0) {
return 0;
} else {
return 1;
}
}

async function readCurrentTemplate(stack: cxapi.SynthesizedStack, templatePath?: string): Promise<{ [key: string]: any }> {
if (templatePath) {
if (!await fs.pathExists(templatePath)) {
throw new Error(`There is no file at ${templatePath}`);
}
const fileContent = await fs.readFile(templatePath, { encoding: 'UTF-8' });
return parseTemplate(fileContent);
} else {
const stackName = renames.finalName(stack.name);
debug(`Reading existing template for stack ${stackName}.`);

const cfn = await aws.cloudFormation(stack.environment, Mode.ForReading);
try {
const response = await cfn.getTemplate({ StackName: stackName }).promise();
return (response.TemplateBody && parseTemplate(response.TemplateBody)) || {};
} catch (e) {
if (e.code === 'ValidationError' && e.message === `Stack with id ${stackName} does not exist`) {
return {};
} else {
throw e;
}
}
}

/* Attempt to parse YAML, fall back to JSON. */
function parseTemplate(text: string): any {
return deserializeStructure(text);
}
}

/**
* Match a single stack from the list of available stacks
*/
Expand Down
42 changes: 37 additions & 5 deletions packages/aws-cdk/lib/api/cxapp/stacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import colors = require('colors/safe');
import minimatch = require('minimatch');
import contextproviders = require('../../context-providers');
import { debug, error, print, warning } from '../../logging';
import { Renames } from '../../renames';
import { Configuration, Settings } from '../../settings';
import cdkUtil = require('../../util');
import { SDK } from '../util/sdk';
Expand Down Expand Up @@ -42,6 +43,11 @@ export interface AppStacksProps {
*/
aws: SDK;

/**
* Renames to apply
*/
renames?: Renames;

/**
* Callback invoked to synthesize the actual stacks
*/
Expand All @@ -59,8 +65,10 @@ export class AppStacks {
* we can invoke it once and cache the response for subsequent calls.
*/
private cachedResponse?: cxapi.SynthesizeResponse;
private readonly renames: Renames;

constructor(private readonly props: AppStacksProps) {
this.renames = props.renames || new Renames({});
}

/**
Expand All @@ -69,7 +77,7 @@ export class AppStacks {
* It's an error if there are no stacks to select, or if one of the requested parameters
* refers to a nonexistant stack.
*/
public async selectStacks(selectors: string[], extendedSelection: ExtendedStackSelection): Promise<cxapi.SynthesizedStack[]> {
public async selectStacks(selectors: string[], extendedSelection: ExtendedStackSelection): Promise<SelectedStack[]> {
selectors = selectors.filter(s => s != null); // filter null/undefined

const stacks: cxapi.SynthesizedStack[] = await this.listStacks();
Expand All @@ -79,7 +87,7 @@ export class AppStacks {

if (selectors.length === 0) {
debug('Stack name not specified, so defaulting to all available stacks: ' + listStackNames(stacks));
return stacks;
return this.applyRenames(stacks);
}

const allStacks = new Map<string, cxapi.SynthesizedStack>();
Expand Down Expand Up @@ -118,7 +126,7 @@ export class AppStacks {

// Only check selected stacks for errors
this.processMessages(selectedList);
return selectedList;
return this.applyRenames(selectedList);
}

/**
Expand All @@ -128,6 +136,8 @@ export class AppStacks {
* topologically sorted order. If there are dependencies that are not in the
* set, they will be ignored; it is the user's responsibility that the
* non-selected stacks have already been deployed previously.
*
* Renames are *NOT* applied in list mode.
*/
public async listStacks(): Promise<cxapi.SynthesizedStack[]> {
const response = await this.synthesizeStacks();
Expand All @@ -137,13 +147,13 @@ export class AppStacks {
/**
* Synthesize a single stack
*/
public async synthesizeStack(stackName: string): Promise<cxapi.SynthesizedStack> {
public async synthesizeStack(stackName: string): Promise<SelectedStack> {
const resp = await this.synthesizeStacks();
const stack = resp.stacks.find(s => s.name === stackName);
if (!stack) {
throw new Error(`Stack ${stackName} not found`);
}
return stack;
return this.applyRenames([stack])[0];
}

/**
Expand Down Expand Up @@ -253,6 +263,21 @@ export class AppStacks {
logFn(` ${entry.trace.join('\n ')}`);
}
}

private applyRenames(stacks: cxapi.SynthesizedStack[]): SelectedStack[] {
this.renames.validateSelectedStacks(stacks);

const ret = [];
for (const stack of stacks) {
ret.push({
...stack,
originalName: stack.name,
name: this.renames.finalName(stack.name),
});
}

return ret;
}
}

/**
Expand Down Expand Up @@ -335,4 +360,11 @@ function includeUpstreamStacks(selectedStacks: Map<string, cxapi.SynthesizedStac
if (added.length > 0) {
print('Including dependency stacks: %s', colors.bold(added.join(', ')));
}
}

export interface SelectedStack extends cxapi.SynthesizedStack {
/**
* The original name of the stack before renaming
*/
originalName: string;
}
Loading