Skip to content

Commit 9c3c5c7

Browse files
author
Elad Ben-Israel
authored
feat(toolkit): improve diff user interface (#1187)
- When possible, display element's construct path alongside logical ID (fixes #1121) - Sort changes according to type: removed > added > updated > other - Add section headers: parameters, resources, output (fixes #1120) - Reduce clutter and emojis To display construct path we fuse metadata from the synthesized output (CDK metadata) and info from the the "aws:cdk:path" CloudFormation metadata (if exists).
1 parent ef0017a commit 9c3c5c7

File tree

4 files changed

+179
-53
lines changed

4 files changed

+179
-53
lines changed

Diff for: packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts

+31-1
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,40 @@ export class DifferenceCollection<V, T extends Difference<V>> {
137137
return new DifferenceCollection<V, T>(newChanges);
138138
}
139139

140+
/**
141+
* Invokes `cb` for all changes in this collection.
142+
*
143+
* Changes will be sorted as follows:
144+
* - Removed
145+
* - Added
146+
* - Updated
147+
* - Others
148+
*
149+
* @param cb
150+
*/
140151
public forEach(cb: (logicalId: string, change: T) => any): void {
152+
const removed = new Array<{ logicalId: string, change: T }>();
153+
const added = new Array<{ logicalId: string, change: T }>();
154+
const updated = new Array<{ logicalId: string, change: T }>();
155+
const others = new Array<{ logicalId: string, change: T }>();
156+
141157
for (const logicalId of this.logicalIds) {
142-
cb(logicalId, this.changes[logicalId]!);
158+
const change: T = this.changes[logicalId]!;
159+
if (change.isAddition) {
160+
added.push({ logicalId, change });
161+
} else if (change.isRemoval) {
162+
removed.push({ logicalId, change });
163+
} else if (change.isUpdate) {
164+
updated.push({ logicalId, change });
165+
} else {
166+
others.push({ logicalId, change });
167+
}
143168
}
169+
170+
removed.forEach(v => cb(v.logicalId, v.change));
171+
added.forEach(v => cb(v.logicalId, v.change));
172+
updated.forEach(v => cb(v.logicalId, v.change));
173+
others.forEach(v => cb(v.logicalId, v.change));
144174
}
145175
}
146176

Diff for: packages/@aws-cdk/cloudformation-diff/lib/format.ts

+131-51
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,86 @@
1+
import cxapi = require('@aws-cdk/cx-api');
12
import colors = require('colors/safe');
23
import { format } from 'util';
34
import { Difference, isPropertyDifference, ResourceDifference, ResourceImpact } from './diff-template';
4-
import { TemplateDiff } from './diff/types';
5+
import { DifferenceCollection, TemplateDiff } from './diff/types';
56
import { deepEqual } from './diff/util';
67

78
/**
89
* Renders template differences to the process' console.
910
*
1011
* @param templateDiff TemplateDiff to be rendered to the console.
12+
* @param logicalToPathMap A map from logical ID to construct path. Useful in
13+
* case there is no aws:cdk:path metadata in the template.
1114
*/
12-
export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: TemplateDiff) {
15+
export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: TemplateDiff, logicalToPathMap: { [logicalId: string]: string } = { }) {
1316
function print(fmt: string, ...args: any[]) {
1417
stream.write(colors.white(format(fmt, ...args)) + '\n');
1518
}
1619

17-
const ADDITION = colors.green('[+]');
18-
const UPDATE = colors.yellow('[~]');
20+
const ADDITION = colors.green('[+]'); const UPDATE = colors.yellow('[~]');
1921
const REMOVAL = colors.red('[-]');
2022

21-
formatDifference('AWSTemplateFormatVersion', templateDiff.awsTemplateFormatVersion);
22-
formatDifference('Transform', templateDiff.transform);
23-
formatDifference('Description', templateDiff.description);
24-
templateDiff.parameters.forEach(formatDifference);
25-
templateDiff.metadata.forEach(formatDifference);
26-
templateDiff.mappings.forEach(formatDifference);
27-
templateDiff.conditions.forEach(formatDifference);
28-
templateDiff.resources.forEach(formatResourceDifference);
29-
templateDiff.outputs.forEach(formatDifference);
30-
templateDiff.unknown.forEach(formatDifference);
23+
if (templateDiff.awsTemplateFormatVersion || templateDiff.transform || templateDiff.description) {
24+
printSectionHeader('Template');
25+
formatDifference('AWSTemplateFormatVersion', 'AWSTemplateFormatVersion', templateDiff.awsTemplateFormatVersion);
26+
formatDifference('Transform', 'Transform', templateDiff.transform);
27+
formatDifference('Description', 'Description', templateDiff.description);
28+
printSectionFooter();
29+
}
30+
31+
formatSection('Parameters', 'Parameter', templateDiff.parameters);
32+
formatSection('Metadata', 'Metadata', templateDiff.metadata);
33+
formatSection('Mappings', 'Mapping', templateDiff.mappings);
34+
formatSection('Conditions', 'Condition', templateDiff.conditions);
35+
formatSection('Resources', 'Resource', templateDiff.resources, formatResourceDifference);
36+
formatSection('Outputs', 'Output', templateDiff.outputs);
37+
formatSection('Other Changes', 'Unknown', templateDiff.unknown);
38+
39+
function formatSection<V, T extends Difference<V>>(
40+
title: string,
41+
entryType: string,
42+
collection: DifferenceCollection<V, T>,
43+
formatter: (type: string, id: string, diff: T) => void = formatDifference) {
44+
45+
if (collection.count === 0) {
46+
return;
47+
}
48+
49+
printSectionHeader(title);
50+
collection.forEach((id, diff) => formatter(entryType, id, diff));
51+
printSectionFooter();
52+
}
53+
54+
function printSectionHeader(title: string) {
55+
print(colors.underline(colors.bold(title)));
56+
}
57+
58+
function printSectionFooter() {
59+
print('');
60+
}
3161

3262
/**
3363
* Print a simple difference for a given named entity.
3464
*
35-
* @param name the name of the entity that is different.
65+
* @param logicalId the name of the entity that is different.
3666
* @param diff the difference to be rendered.
3767
*/
38-
function formatDifference(name: string, diff: Difference<any> | undefined) {
68+
function formatDifference(type: string, logicalId: string, diff: Difference<any> | undefined) {
3969
if (!diff) { return; }
70+
71+
let value;
72+
4073
const oldValue = formatValue(diff.oldValue, colors.red);
4174
const newValue = formatValue(diff.newValue, colors.green);
4275
if (diff.isAddition) {
43-
print('%s Added %s: %s', ADDITION, colors.blue(name), newValue);
76+
value = newValue;
4477
} else if (diff.isUpdate) {
45-
print('%s Updated %s: %s to %s', UPDATE, colors.blue(name), oldValue, newValue);
78+
value = `${oldValue} to ${newValue}`;
4679
} else if (diff.isRemoval) {
47-
print('%s Removed %s: %s', REMOVAL, colors.blue(name), oldValue);
80+
value = oldValue;
4881
}
82+
83+
print(`${formatPrefix(diff)} ${colors.cyan(type)} ${formatLogicalId(logicalId)}: ${value}`);
4984
}
5085

5186
/**
@@ -54,34 +89,28 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp
5489
* @param logicalId the logical ID of the resource that changed.
5590
* @param diff the change to be rendered.
5691
*/
57-
function formatResourceDifference(logicalId: string, diff: ResourceDifference) {
58-
if (diff.isAddition) {
59-
print('%s %s %s (type: %s)',
60-
ADDITION,
61-
formatImpact(diff.changeImpact),
62-
colors.blue(logicalId),
63-
formatValue(diff.newResourceType, colors.green));
64-
} else if (diff.isUpdate) {
65-
print('%s %s %s (type: %s)',
66-
UPDATE,
67-
formatImpact(diff.changeImpact),
68-
colors.blue(logicalId),
69-
formatValue(diff.newResourceType, colors.green));
92+
function formatResourceDifference(_type: string, logicalId: string, diff: ResourceDifference) {
93+
const resourceType = diff.isRemoval ? diff.oldResourceType : diff.newResourceType;
94+
95+
// tslint:disable-next-line:max-line-length
96+
print(`${formatPrefix(diff)} ${formatValue(resourceType, colors.cyan)} ${formatLogicalId(logicalId, diff)} ${formatImpact(diff.changeImpact)}`);
97+
98+
if (diff.isUpdate) {
7099
let processedCount = 0;
71-
diff.forEach((type, name, values) => {
100+
diff.forEach((_, name, values) => {
72101
processedCount += 1;
73-
if (type === 'Property') { name = `.${name}`; }
74102
formatTreeDiff(name, values, processedCount === diff.count);
75103
});
76-
} else if (diff.isRemoval) {
77-
print('%s %s %s (type: %s)',
78-
REMOVAL,
79-
formatImpact(diff.changeImpact),
80-
colors.blue(logicalId),
81-
formatValue(diff.oldResourceType, colors.green));
82104
}
83105
}
84106

107+
function formatPrefix<T>(diff: Difference<T>) {
108+
if (diff.isAddition) { return ADDITION; }
109+
if (diff.isUpdate) { return UPDATE; }
110+
if (diff.isRemoval) { return REMOVAL; }
111+
return colors.white('[?]');
112+
}
113+
85114
/**
86115
* @param value the value to be formatted.
87116
* @param color the color to be used.
@@ -101,17 +130,16 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp
101130
function formatImpact(impact: ResourceImpact) {
102131
switch (impact) {
103132
case ResourceImpact.MAY_REPLACE:
104-
return colors.yellow('⚠️ May be replacing');
133+
return colors.italic(colors.yellow('may be replaced'));
105134
case ResourceImpact.WILL_REPLACE:
106-
return colors.bold(colors.yellow('⚠️ Replacing'));
135+
return colors.italic(colors.bold(colors.yellow('replace')));
107136
case ResourceImpact.WILL_DESTROY:
108-
return colors.bold(colors.red('☢️ Destroying'));
137+
return colors.italic(colors.bold(colors.red('destroy')));
109138
case ResourceImpact.WILL_ORPHAN:
110-
return colors.red('🗑 Orphaning');
139+
return colors.italic(colors.yellow('orphan'));
111140
case ResourceImpact.WILL_UPDATE:
112-
return colors.green('🛠 Updating');
113141
case ResourceImpact.WILL_CREATE:
114-
return colors.green('🆕 Creating');
142+
return ''; // no extra info is gained here
115143
}
116144
}
117145

@@ -130,7 +158,7 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp
130158
additionalInfo = ' (requires replacement)';
131159
}
132160
}
133-
print(' %s─ %s %s%s:', last ? '└' : '├', changeTag(diff.oldValue, diff.newValue), colors.blue(`${name}`), additionalInfo);
161+
print(' %s─ %s %s%s', last ? '└' : '├', changeTag(diff.oldValue, diff.newValue), name, additionalInfo);
134162
return formatObjectDiff(diff.oldValue, diff.newValue, ` ${last ? ' ' : '│'}`);
135163
}
136164

@@ -145,12 +173,12 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp
145173
function formatObjectDiff(oldObject: any, newObject: any, linePrefix: string) {
146174
if ((typeof oldObject !== typeof newObject) || Array.isArray(oldObject) || typeof oldObject === 'string' || typeof oldObject === 'number') {
147175
if (oldObject !== undefined && newObject !== undefined) {
148-
print('%s ├─ %s Old value: %s', linePrefix, REMOVAL, formatValue(oldObject, colors.red));
149-
print('%s └─ %s New value: %s', linePrefix, ADDITION, formatValue(newObject, colors.green));
176+
print('%s ├─ %s %s', linePrefix, REMOVAL, formatValue(oldObject, colors.red));
177+
print('%s └─ %s %s', linePrefix, ADDITION, formatValue(newObject, colors.green));
150178
} else if (oldObject !== undefined /* && newObject === undefined */) {
151-
print('%s └─ Old value: %s', linePrefix, formatValue(oldObject, colors.red));
179+
print('%s └─ %s', linePrefix, formatValue(oldObject, colors.red));
152180
} else /* if (oldObject === undefined && newObject !== undefined) */ {
153-
print('%s └─ New value: %s', linePrefix, formatValue(newObject, colors.green));
181+
print('%s └─ %s', linePrefix, formatValue(newObject, colors.green));
154182
}
155183
return;
156184
}
@@ -189,4 +217,56 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp
189217
return ADDITION;
190218
}
191219
}
220+
221+
function formatLogicalId(logicalId: string, diff?: ResourceDifference) {
222+
// if we have a path in the map, return it
223+
const path = logicalToPathMap[logicalId];
224+
if (path) {
225+
// first component of path is the stack name, so let's remove that
226+
return normalizePath(path);
227+
}
228+
229+
// if we don't have in our map, it might be a deleted resource, so let's try the
230+
// template metadata
231+
const oldPathMetadata = diff && diff.oldValue && diff.oldValue.Metadata && diff.oldValue.Metadata[cxapi.PATH_METADATA_KEY];
232+
if (oldPathMetadata) {
233+
return normalizePath(oldPathMetadata);
234+
}
235+
236+
const newPathMetadata = diff && diff.newValue && diff.newValue.Metadata && diff.newValue.Metadata[cxapi.PATH_METADATA_KEY];
237+
if (newPathMetadata) {
238+
return normalizePath(newPathMetadata);
239+
}
240+
241+
// couldn't figure out the path, just return the logical ID
242+
return logicalId;
243+
244+
/**
245+
* Path is supposed to start with "/stack-name". If this is the case (i.e. path has more than
246+
* two components, we remove the first part. Otherwise, we just use the full path.
247+
* @param p
248+
*/
249+
function normalizePath(p: string) {
250+
if (p.startsWith('/')) {
251+
p = p.substr(1);
252+
}
253+
254+
let parts = p.split('/');
255+
if (parts.length > 1) {
256+
parts = parts.slice(1);
257+
258+
// remove the last component if it's "Resource" or "Default" (if we have more than a single component)
259+
if (parts.length > 1) {
260+
const last = parts[parts.length - 1];
261+
if (last === 'Resource' || last === 'Default') {
262+
parts = parts.slice(0, parts.length - 1);
263+
}
264+
}
265+
266+
p = parts.join('/');
267+
}
268+
269+
return `${p} ${colors.gray(logicalId)}`;
270+
}
271+
}
192272
}

Diff for: packages/@aws-cdk/cloudformation-diff/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"license": "Apache-2.0",
2525
"dependencies": {
2626
"@aws-cdk/cfnspec": "^0.17.0",
27+
"@aws-cdk/cx-api": "^0.17.0",
2728
"colors": "^1.2.1",
2829
"source-map-support": "^0.5.6"
2930
},

Diff for: packages/aws-cdk/lib/diff.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,24 @@ export function printStackDiff(oldTemplate: any, newTemplate: cxapi.SynthesizedS
2525
}
2626

2727
if (!diff.isEmpty) {
28-
cfnDiff.formatDifferences(process.stderr, diff);
28+
cfnDiff.formatDifferences(process.stderr, diff, buildLogicalToPathMap(newTemplate));
2929
} else {
3030
print(colors.green('There were no differences'));
3131
}
32+
3233
return diff.count;
3334
}
35+
36+
function buildLogicalToPathMap(template: cxapi.SynthesizedStack) {
37+
const map: { [id: string]: string } = {};
38+
for (const path of Object.keys(template.metadata)) {
39+
const md = template.metadata[path];
40+
for (const e of md) {
41+
if (e.type === 'aws:cdk:logicalId') {
42+
const logical = e.data;
43+
map[logical] = path;
44+
}
45+
}
46+
}
47+
return map;
48+
}

0 commit comments

Comments
 (0)