Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 72d2535

Browse files
rix0rrrRomainMuller
authored andcommittedFeb 28, 2019
fix(toolkit): support diff on multiple stacks (#1855)
If there are stack dependencies, 'diff' would fail because it does not know how to diff multiple stacks. Make diff support the same stack selection mechanisms as 'cdk deploy'. Move 'stack rename' facilities into the class that deals with the CDK app, which is the source of thruth for stacks. This way, all downstream code doesn't have to deal with the renames every time. Start factoring out toolkit code into logical layers. Introducing the class `CdkToolkit`, which represents the toolkit logic and forms the bridge between `AppStacks` which deals with the CDK model source (probably needs to be renamed to something better) and `CfnProvisioner`, which deals with the deployed stacks. N.B.: The indirection to a provisioner class with an interface is because the interface is going to be complex (therefore, interface over a set of functions that take callbacks) and we want to depend just on the interface so it's easy to stub out for testing.
1 parent 5c3431b commit 72d2535

File tree

8 files changed

+342
-76
lines changed

8 files changed

+342
-76
lines changed
 

‎packages/@aws-cdk/cloudformation-diff/lib/format.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { SecurityGroupChanges } from './network/security-group-changes';
1111
// tslint:disable-next-line:no-var-requires
1212
const { structuredPatch } = require('diff');
1313

14+
export interface FormatStream extends NodeJS.WritableStream {
15+
columns?: number;
16+
}
17+
1418
/**
1519
* Renders template differences to the process' console.
1620
*
@@ -20,7 +24,7 @@ const { structuredPatch } = require('diff');
2024
* case there is no aws:cdk:path metadata in the template.
2125
* @param context the number of context lines to use in arbitrary JSON diff (defaults to 3).
2226
*/
23-
export function formatDifferences(stream: NodeJS.WriteStream,
27+
export function formatDifferences(stream: FormatStream,
2428
templateDiff: TemplateDiff,
2529
logicalToPathMap: { [logicalId: string]: string } = { },
2630
context: number = 3) {
@@ -72,7 +76,7 @@ const UPDATE = colors.yellow('[~]');
7276
const REMOVAL = colors.red('[-]');
7377

7478
class Formatter {
75-
constructor(private readonly stream: NodeJS.WriteStream,
79+
constructor(private readonly stream: FormatStream,
7680
private readonly logicalToPathMap: { [logicalId: string]: string },
7781
diff?: TemplateDiff,
7882
private readonly context: number = 3) {

‎packages/aws-cdk/bin/cdk.ts

+33-67
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
11
#!/usr/bin/env node
22
import 'source-map-support/register';
33

4-
import cxapi = require('@aws-cdk/cx-api');
54
import colors = require('colors/safe');
65
import fs = require('fs-extra');
76
import util = require('util');
87
import yargs = require('yargs');
98

10-
import { bootstrapEnvironment, deployStack, destroyStack, loadToolkitInfo, Mode, SDK } from '../lib';
9+
import { bootstrapEnvironment, deployStack, destroyStack, loadToolkitInfo, SDK } from '../lib';
1110
import { environmentsFromDescriptors, globEnvironmentsFromStacks } from '../lib/api/cxapp/environments';
1211
import { execProgram } from '../lib/api/cxapp/exec';
1312
import { AppStacks, ExtendedStackSelection, listStackNames } from '../lib/api/cxapp/stacks';
13+
import { CloudFormationDeploymentTarget } from '../lib/api/deployment-target';
1414
import { leftPad } from '../lib/api/util/string-manipulation';
15-
import { printSecurityDiff, printStackDiff, RequireApproval } from '../lib/diff';
15+
import { CdkToolkit } from '../lib/cdk-toolkit';
16+
import { printSecurityDiff, RequireApproval } from '../lib/diff';
1617
import { availableInitLanguages, cliInit, printAvailableTemplates } from '../lib/init';
1718
import { interactive } from '../lib/interactive';
1819
import { data, debug, error, highlight, print, setVerbose, success } from '../lib/logging';
1920
import { PluginHost } from '../lib/plugin';
2021
import { parseRenames } from '../lib/renames';
21-
import { deserializeStructure, serializeStructure } from '../lib/serialize';
22+
import { serializeStructure } from '../lib/serialize';
2223
import { Configuration, Settings } from '../lib/settings';
2324
import { VERSION } from '../lib/version';
2425

@@ -66,7 +67,8 @@ async function parseCommandLineArguments() {
6667
.command('destroy [STACKS..]', 'Destroy the stack(s) named STACKS', yargs => yargs
6768
.option('exclusively', { type: 'boolean', alias: 'x', desc: 'only deploy requested stacks, don\'t include dependees' })
6869
.option('force', { type: 'boolean', alias: 'f', desc: 'Do not ask for confirmation before destroying the stacks' }))
69-
.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
70+
.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
71+
.option('exclusively', { type: 'boolean', alias: 'e', desc: 'only diff requested stacks, don\'t include dependencies' })
7072
.option('context-lines', { type: 'number', desc: 'number of context lines to include in arbitrary JSON diff rendering', default: 3 })
7173
.option('template', { type: 'string', desc: 'the path to the CloudFormation template to compare with' })
7274
.option('strict', { type: 'boolean', desc: 'do not filter out AWS::CDK::Metadata resources', default: false }))
@@ -107,13 +109,17 @@ async function initCommandLine() {
107109
const configuration = new Configuration(argv);
108110
await configuration.load();
109111

112+
const provisioner = new CloudFormationDeploymentTarget({ aws });
113+
110114
const appStacks = new AppStacks({
111115
verbose: argv.trace || argv.verbose,
112116
ignoreErrors: argv.ignoreErrors,
113117
strict: argv.strict,
114-
configuration, aws, synthesizer: execProgram });
115-
116-
const renames = parseRenames(argv.rename);
118+
configuration,
119+
aws,
120+
synthesizer: execProgram,
121+
renames: parseRenames(argv.rename)
122+
});
117123

118124
/** Function to load plug-ins, using configurations additively. */
119125
function loadPlugins(...settings: Settings[]) {
@@ -165,13 +171,21 @@ async function initCommandLine() {
165171
args.STACKS = args.STACKS || [];
166172
args.ENVIRONMENTS = args.ENVIRONMENTS || [];
167173

174+
const cli = new CdkToolkit({ appStacks, provisioner });
175+
168176
switch (command) {
169177
case 'ls':
170178
case 'list':
171179
return await cliList({ long: args.long });
172180

173181
case 'diff':
174-
return await diffStack(await findStack(args.STACK), args.template, args.strict, args.contextLines);
182+
return await cli.diff({
183+
stackNames: args.STACKS,
184+
exclusively: args.exclusively,
185+
templatePath: args.template,
186+
strict: args.strict,
187+
contextLines: args.contextLines
188+
});
175189

176190
case 'bootstrap':
177191
return await cliBootstrap(args.ENVIRONMENTS, toolkitStackName, args.roleArn);
@@ -258,7 +272,6 @@ async function initCommandLine() {
258272
const autoSelectDependencies = !exclusively && outputDir !== undefined;
259273

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

263276
if (doInteractive) {
264277
if (stacks.length !== 1) {
@@ -294,9 +307,8 @@ async function initCommandLine() {
294307

295308
let i = 0;
296309
for (const stack of stacks) {
297-
const finalName = renames.finalName(stack.name);
298310
const prefix = numbered ? leftPad(`${i}`, 3, '0') + '.' : '';
299-
const fileName = `${outputDir}/${prefix}${finalName}.template.${json ? 'json' : 'yaml'}`;
311+
const fileName = `${outputDir}/${prefix}${stack.name}.template.${json ? 'json' : 'yaml'}`;
300312
highlight(fileName);
301313
await fs.writeFile(fileName, toJsonOrYaml(stack.template));
302314
i++;
@@ -337,7 +349,6 @@ async function initCommandLine() {
337349
if (requireApproval === undefined) { requireApproval = RequireApproval.Broadening; }
338350
339351
const stacks = await appStacks.selectStacks(stackNames, exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream);
340-
renames.validateSelectedStacks(stacks);
341352
342353
for (const stack of stacks) {
343354
if (stacks.length !== 1) { highlight(stack.name); }
@@ -346,10 +357,9 @@ async function initCommandLine() {
346357
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.`);
347358
}
348359
const toolkitInfo = await loadToolkitInfo(stack.environment, aws, toolkitStackName);
349-
const deployName = renames.finalName(stack.name);
350360
351361
if (requireApproval !== RequireApproval.Never) {
352-
const currentTemplate = await readCurrentTemplate(stack);
362+
const currentTemplate = await provisioner.readCurrentTemplate(stack);
353363
if (printSecurityDiff(currentTemplate, stack, requireApproval)) {
354364
355365
// only talk to user if we STDIN is a terminal (otherwise, fail)
@@ -364,14 +374,14 @@ async function initCommandLine() {
364374
}
365375
}
366376
367-
if (deployName !== stack.name) {
368-
print('%s: deploying... (was %s)', colors.bold(deployName), colors.bold(stack.name));
377+
if (stack.name !== stack.originalName) {
378+
print('%s: deploying... (was %s)', colors.bold(stack.name), colors.bold(stack.originalName));
369379
} else {
370380
print('%s: deploying...', colors.bold(stack.name));
371381
}
372382
373383
try {
374-
const result = await deployStack({ stack, sdk: aws, toolkitInfo, deployName, roleArn, ci });
384+
const result = await deployStack({ stack, sdk: aws, toolkitInfo, deployName: stack.name, roleArn, ci });
375385
const message = result.noOp
376386
? ` %s (no changes)`
377387
: ` %s`;
@@ -384,7 +394,7 @@ async function initCommandLine() {
384394
385395
for (const name of Object.keys(result.outputs)) {
386396
const value = result.outputs[name];
387-
print('%s.%s = %s', colors.cyan(deployName), colors.cyan(name), colors.underline(colors.cyan(value)));
397+
print('%s.%s = %s', colors.cyan(stack.name), colors.cyan(name), colors.underline(colors.cyan(value)));
388398
}
389399
390400
print('\nStack ARN:');
@@ -403,8 +413,6 @@ async function initCommandLine() {
403413
// The stacks will have been ordered for deployment, so reverse them for deletion.
404414
stacks.reverse();
405415
406-
renames.validateSelectedStacks(stacks);
407-
408416
if (!force) {
409417
// tslint:disable-next-line:max-line-length
410418
const confirmed = await confirm(`Are you sure you want to delete: ${colors.blue(stacks.map(s => s.name).join(', '))} (y/n)?`);
@@ -414,59 +422,17 @@ async function initCommandLine() {
414422
}
415423
416424
for (const stack of stacks) {
417-
const deployName = renames.finalName(stack.name);
418-
419-
success('%s: destroying...', colors.blue(deployName));
425+
success('%s: destroying...', colors.blue(stack.name));
420426
try {
421-
await destroyStack({ stack, sdk: aws, deployName, roleArn });
422-
success('\n ✅ %s: destroyed', colors.blue(deployName));
427+
await destroyStack({ stack, sdk: aws, deployName: stack.name, roleArn });
428+
success('\n ✅ %s: destroyed', colors.blue(stack.name));
423429
} catch (e) {
424-
error('\n ❌ %s: destroy failed', colors.blue(deployName), e);
430+
error('\n ❌ %s: destroy failed', colors.blue(stack.name), e);
425431
throw e;
426432
}
427433
}
428434
}
429435
430-
async function diffStack(stackName: string, templatePath: string | undefined, strict: boolean, context: number): Promise<number> {
431-
const stack = await appStacks.synthesizeStack(stackName);
432-
const currentTemplate = await readCurrentTemplate(stack, templatePath);
433-
if (printStackDiff(currentTemplate, stack, strict, context) === 0) {
434-
return 0;
435-
} else {
436-
return 1;
437-
}
438-
}
439-
440-
async function readCurrentTemplate(stack: cxapi.SynthesizedStack, templatePath?: string): Promise<{ [key: string]: any }> {
441-
if (templatePath) {
442-
if (!await fs.pathExists(templatePath)) {
443-
throw new Error(`There is no file at ${templatePath}`);
444-
}
445-
const fileContent = await fs.readFile(templatePath, { encoding: 'UTF-8' });
446-
return parseTemplate(fileContent);
447-
} else {
448-
const stackName = renames.finalName(stack.name);
449-
debug(`Reading existing template for stack ${stackName}.`);
450-
451-
const cfn = await aws.cloudFormation(stack.environment, Mode.ForReading);
452-
try {
453-
const response = await cfn.getTemplate({ StackName: stackName }).promise();
454-
return (response.TemplateBody && parseTemplate(response.TemplateBody)) || {};
455-
} catch (e) {
456-
if (e.code === 'ValidationError' && e.message === `Stack with id ${stackName} does not exist`) {
457-
return {};
458-
} else {
459-
throw e;
460-
}
461-
}
462-
}
463-
464-
/* Attempt to parse YAML, fall back to JSON. */
465-
function parseTemplate(text: string): any {
466-
return deserializeStructure(text);
467-
}
468-
}
469-
470436
/**
471437
* Match a single stack from the list of available stacks
472438
*/

‎packages/aws-cdk/lib/api/cxapp/stacks.ts

+37-5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import colors = require('colors/safe');
33
import minimatch = require('minimatch');
44
import contextproviders = require('../../context-providers');
55
import { debug, error, print, warning } from '../../logging';
6+
import { Renames } from '../../renames';
67
import { Configuration } from '../../settings';
78
import cdkUtil = require('../../util');
89
import { SDK } from '../util/sdk';
@@ -42,6 +43,11 @@ export interface AppStacksProps {
4243
*/
4344
aws: SDK;
4445

46+
/**
47+
* Renames to apply
48+
*/
49+
renames?: Renames;
50+
4551
/**
4652
* Callback invoked to synthesize the actual stacks
4753
*/
@@ -59,8 +65,10 @@ export class AppStacks {
5965
* we can invoke it once and cache the response for subsequent calls.
6066
*/
6167
private cachedResponse?: cxapi.SynthesizeResponse;
68+
private readonly renames: Renames;
6269

6370
constructor(private readonly props: AppStacksProps) {
71+
this.renames = props.renames || new Renames({});
6472
}
6573

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

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

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

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

119127
// Only check selected stacks for errors
120128
this.processMessages(selectedList);
121-
return selectedList;
129+
return this.applyRenames(selectedList);
122130
}
123131

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

149159
/**
@@ -253,6 +263,21 @@ export class AppStacks {
253263
logFn(` ${entry.trace.join('\n ')}`);
254264
}
255265
}
266+
267+
private applyRenames(stacks: cxapi.SynthesizedStack[]): SelectedStack[] {
268+
this.renames.validateSelectedStacks(stacks);
269+
270+
const ret = [];
271+
for (const stack of stacks) {
272+
ret.push({
273+
...stack,
274+
originalName: stack.name,
275+
name: this.renames.finalName(stack.name),
276+
});
277+
}
278+
279+
return ret;
280+
}
256281
}
257282

258283
/**
@@ -335,4 +360,11 @@ function includeUpstreamStacks(selectedStacks: Map<string, cxapi.SynthesizedStac
335360
if (added.length > 0) {
336361
print('Including dependency stacks: %s', colors.bold(added.join(', ')));
337362
}
363+
}
364+
365+
export interface SelectedStack extends cxapi.SynthesizedStack {
366+
/**
367+
* The original name of the stack before renaming
368+
*/
369+
originalName: string;
338370
}
There was a problem loading the remainder of the diff.

0 commit comments

Comments
 (0)