Skip to content

Commit 607f997

Browse files
authored
feat(diff): Better diff of random objects (#1488)
Use a unified diff format to render differences in arbitrary values, making it easier to understand what is changing in possibly large JSON structures, for example. The number of context lines used when rendering the JSON differences can be customized using the `--context-lines` option of `cdk diff`, which has a default value of `3`.
1 parent 5b24583 commit 607f997

File tree

6 files changed

+127
-22
lines changed

6 files changed

+127
-22
lines changed

packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const DIFF_HANDLERS: HandlerRegistry = {
3232
* Compare two CloudFormation templates and return semantic differences between them.
3333
*
3434
* @param currentTemplate the current state of the stack.
35-
* @param newTemplate the target state of the stack.
35+
* @param newTemplate the target state of the stack.
3636
*
3737
* @returns a +types.TemplateDiff+ object that represents the changes that will happen if
3838
* a stack which current state is described by +currentTemplate+ is updated with
@@ -144,4 +144,4 @@ function deepCopy(x: any): any {
144144
}
145145

146146
return x;
147-
}
147+
}

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

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,23 @@ import { deepEqual } from './diff/util';
88
import { IamChanges } from './iam/iam-changes';
99
import { SecurityGroupChanges } from './network/security-group-changes';
1010

11+
// tslint:disable-next-line:no-var-requires
12+
const { structuredPatch } = require('diff');
13+
1114
/**
1215
* Renders template differences to the process' console.
1316
*
14-
* @param templateDiff TemplateDiff to be rendered to the console.
17+
* @param stream The IO stream where to output the rendered diff.
18+
* @param templateDiff TemplateDiff to be rendered to the console.
1519
* @param logicalToPathMap A map from logical ID to construct path. Useful in
1620
* case there is no aws:cdk:path metadata in the template.
21+
* @param context the number of context lines to use in arbitrary JSON diff (defaults to 3).
1722
*/
18-
export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: TemplateDiff, logicalToPathMap: { [logicalId: string]: string } = { }) {
19-
const formatter = new Formatter(stream, logicalToPathMap, templateDiff);
23+
export function formatDifferences(stream: NodeJS.WriteStream,
24+
templateDiff: TemplateDiff,
25+
logicalToPathMap: { [logicalId: string]: string } = { },
26+
context: number = 3) {
27+
const formatter = new Formatter(stream, logicalToPathMap, templateDiff, context);
2028

2129
if (templateDiff.awsTemplateFormatVersion || templateDiff.transform || templateDiff.description) {
2230
formatter.printSectionHeader('Template');
@@ -40,8 +48,11 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp
4048
/**
4149
* Renders a diff of security changes to the given stream
4250
*/
43-
export function formatSecurityChanges(stream: NodeJS.WriteStream, templateDiff: TemplateDiff, logicalToPathMap: {[logicalId: string]: string} = {}) {
44-
const formatter = new Formatter(stream, logicalToPathMap, templateDiff);
51+
export function formatSecurityChanges(stream: NodeJS.WriteStream,
52+
templateDiff: TemplateDiff,
53+
logicalToPathMap: {[logicalId: string]: string} = {},
54+
context?: number) {
55+
const formatter = new Formatter(stream, logicalToPathMap, templateDiff, context);
4556

4657
formatSecurityChangesWithBanner(formatter, templateDiff);
4758
}
@@ -56,11 +67,15 @@ function formatSecurityChangesWithBanner(formatter: Formatter, templateDiff: Tem
5667
}
5768

5869
const ADDITION = colors.green('[+]');
70+
const CONTEXT = colors.grey('[ ]');
5971
const UPDATE = colors.yellow('[~]');
6072
const REMOVAL = colors.red('[-]');
6173

6274
class Formatter {
63-
constructor(private readonly stream: NodeJS.WriteStream, private readonly logicalToPathMap: { [logicalId: string]: string }, diff?: TemplateDiff) {
75+
constructor(private readonly stream: NodeJS.WriteStream,
76+
private readonly logicalToPathMap: { [logicalId: string]: string },
77+
diff?: TemplateDiff,
78+
private readonly context: number = 3) {
6479
// Read additional construct paths from the diff if it is supplied
6580
if (diff) {
6681
this.readConstructPathsFrom(diff);
@@ -126,7 +141,7 @@ class Formatter {
126141
* Print a resource difference for a given logical ID.
127142
*
128143
* @param logicalId the logical ID of the resource that changed.
129-
* @param diff the change to be rendered.
144+
* @param diff the change to be rendered.
130145
*/
131146
public formatResourceDifference(_type: string, logicalId: string, diff: ResourceDifference) {
132147
const resourceType = diff.isRemoval ? diff.oldResourceType : diff.newResourceType;
@@ -184,9 +199,9 @@ class Formatter {
184199

185200
/**
186201
* Renders a tree of differences under a particular name.
187-
* @param name the name of the root of the tree.
188-
* @param diff the difference on the tree.
189-
* @param last whether this is the last node of a parent tree.
202+
* @param name the name of the root of the tree.
203+
* @param diff the difference on the tree.
204+
* @param last whether this is the last node of a parent tree.
190205
*/
191206
public formatTreeDiff(name: string, diff: Difference<any>, last: boolean) {
192207
let additionalInfo = '';
@@ -210,10 +225,19 @@ class Formatter {
210225
* @param linePrefix a prefix (indent-like) to be used on every line.
211226
*/
212227
public formatObjectDiff(oldObject: any, newObject: any, linePrefix: string) {
213-
if ((typeof oldObject !== typeof newObject) || Array.isArray(oldObject) || typeof oldObject === 'string' || typeof oldObject === 'number') {
228+
if ((typeof oldObject !== typeof newObject) || Array.isArray(oldObject) || typeof oldObject === 'string' || typeof oldObject === 'number') {
214229
if (oldObject !== undefined && newObject !== undefined) {
215-
this.print('%s ├─ %s %s', linePrefix, REMOVAL, this.formatValue(oldObject, colors.red));
216-
this.print('%s └─ %s %s', linePrefix, ADDITION, this.formatValue(newObject, colors.green));
230+
if (typeof oldObject === 'object' || typeof newObject === 'object') {
231+
const oldStr = JSON.stringify(oldObject, null, 2);
232+
const newStr = JSON.stringify(newObject, null, 2);
233+
const diff = _diffStrings(oldStr, newStr, this.context);
234+
for (let i = 0 ; i < diff.length ; i++) {
235+
this.print('%s %s %s', linePrefix, i === 0 ? '└─' : ' ', diff[i]);
236+
}
237+
} else {
238+
this.print('%s ├─ %s %s', linePrefix, REMOVAL, this.formatValue(oldObject, colors.red));
239+
this.print('%s └─ %s %s', linePrefix, ADDITION, this.formatValue(newObject, colors.green));
240+
}
217241
} else if (oldObject !== undefined /* && newObject === undefined */) {
218242
this.print('%s └─ %s', linePrefix, this.formatValue(oldObject, colors.red));
219243
} else /* if (oldObject === undefined && newObject !== undefined) */ {
@@ -398,3 +422,75 @@ function stripHorizontalLines(tableRendering: string) {
398422
return cols[1];
399423
}
400424
}
425+
426+
/**
427+
* A patch as returned by ``diff.structuredPatch``.
428+
*/
429+
interface Patch {
430+
/**
431+
* Hunks in the patch.
432+
*/
433+
hunks: ReadonlyArray<PatchHunk>;
434+
}
435+
436+
/**
437+
* A hunk in a patch produced by ``diff.structuredPatch``.
438+
*/
439+
interface PatchHunk {
440+
oldStart: number;
441+
oldLines: number;
442+
newStart: number;
443+
newLines: number;
444+
lines: string[];
445+
}
446+
447+
/**
448+
* Creates a unified diff of two strings.
449+
*
450+
* @param oldStr the "old" version of the string.
451+
* @param newStr the "new" version of the string.
452+
* @param context the number of context lines to use in arbitrary JSON diff.
453+
*
454+
* @returns an array of diff lines.
455+
*/
456+
function _diffStrings(oldStr: string, newStr: string, context: number): string[] {
457+
const patch: Patch = structuredPatch(null, null, oldStr, newStr, null, null, { context });
458+
const result = new Array<string>();
459+
for (const hunk of patch.hunks) {
460+
result.push(colors.magenta(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`));
461+
const baseIndent = _findIndent(hunk.lines);
462+
for (const line of hunk.lines) {
463+
// Don't care about termination newline.
464+
if (line === '\\ No newline at end of file') { continue; }
465+
const marker = line.charAt(0);
466+
const text = line.slice(1 + baseIndent);
467+
switch (marker) {
468+
case ' ':
469+
result.push(`${CONTEXT} ${text}`);
470+
break;
471+
case '+':
472+
result.push(colors.bold(`${ADDITION} ${colors.green(text)}`));
473+
break;
474+
case '-':
475+
result.push(colors.bold(`${REMOVAL} ${colors.red(text)}`));
476+
break;
477+
default:
478+
throw new Error(`Unexpected diff marker: ${marker} (full line: ${line})`);
479+
}
480+
}
481+
}
482+
return result;
483+
484+
function _findIndent(lines: string[]): number {
485+
let indent = Number.MAX_SAFE_INTEGER;
486+
for (const line of lines) {
487+
for (let i = 1 ; i < line.length ; i++) {
488+
if (line.charAt(i) !== ' ') {
489+
indent = indent > i - 1 ? i - 1 : indent;
490+
break;
491+
}
492+
}
493+
}
494+
return indent;
495+
}
496+
}

packages/@aws-cdk/cloudformation-diff/package-lock.json

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@aws-cdk/cloudformation-diff/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@aws-cdk/cx-api": "^0.21.0",
2828
"cli-table": "^0.3.1",
2929
"colors": "^1.2.1",
30+
"diff": "^4.0.1",
3031
"fast-deep-equal": "^2.0.1",
3132
"source-map-support": "^0.5.6"
3233
},

packages/aws-cdk/bin/cdk.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ async function parseCommandLineArguments() {
6060
.command('destroy [STACKS..]', 'Destroy the stack(s) named STACKS', yargs => yargs
6161
.option('force', { type: 'boolean', alias: 'f', desc: 'Do not ask for confirmation before destroying the stacks' }))
6262
.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
63+
.option('context-lines', { type: 'number', desc: 'number of context lines to include in arbitrary JSON diff rendering', default: 3 })
6364
.option('template', { type: 'string', desc: 'the path to the CloudFormation template to compare with' })
6465
.option('strict', { type: 'boolean', desc: 'do not filter out AWS::CDK::Metadata resources', default: false }))
6566
.command('metadata [STACK]', 'Returns all metadata associated with this stack')
@@ -142,7 +143,7 @@ async function initCommandLine() {
142143
return returnValue;
143144
}
144145

145-
async function main(command: string, args: any): Promise<number | string | {} | void> {
146+
async function main(command: string, args: any): Promise<number | string | {} | void> {
146147
const toolkitStackName: string = configuration.combined.get(['toolkitStackName']) || DEFAULT_TOOLKIT_STACK_NAME;
147148

148149
if (toolkitStackName !== DEFAULT_TOOLKIT_STACK_NAME) {
@@ -158,7 +159,7 @@ async function initCommandLine() {
158159
return await cliList({ long: args.long });
159160

160161
case 'diff':
161-
return await diffStack(await findStack(args.STACK), args.template, args.strict);
162+
return await diffStack(await findStack(args.STACK), args.template, args.strict, args.contextLines);
162163

163164
case 'bootstrap':
164165
return await cliBootstrap(args.ENVIRONMENTS, toolkitStackName, args.roleArn);
@@ -383,10 +384,10 @@ async function initCommandLine() {
383384
}
384385
}
385386
386-
async function diffStack(stackName: string, templatePath: string | undefined, strict: boolean): Promise<number> {
387+
async function diffStack(stackName: string, templatePath: string | undefined, strict: boolean, context: number): Promise<number> {
387388
const stack = await appStacks.synthesizeStack(stackName);
388389
const currentTemplate = await readCurrentTemplate(stack, templatePath);
389-
if (printStackDiff(currentTemplate, stack, strict) === 0) {
390+
if (printStackDiff(currentTemplate, stack, strict, context) === 0) {
390391
return 0;
391392
} else {
392393
return 1;

packages/aws-cdk/lib/diff.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import { print, warning } from './logging';
88
*
99
* @param oldTemplate the old/current state of the stack.
1010
* @param newTemplate the new/target state of the stack.
11+
* @param strict do not filter out AWS::CDK::Metadata
12+
* @param context lines of context to use in arbitrary JSON diff
1113
*
1214
* @returns the count of differences that were rendered.
1315
*/
14-
export function printStackDiff(oldTemplate: any, newTemplate: cxapi.SynthesizedStack, strict: boolean): number {
16+
export function printStackDiff(oldTemplate: any, newTemplate: cxapi.SynthesizedStack, strict: boolean, context: number): number {
1517
if (_hasAssets(newTemplate)) {
1618
const issue = 'https://github.com/awslabs/aws-cdk/issues/395';
1719
warning(`The ${newTemplate.name} stack uses assets, which are currently not accounted for in the diff output! See ${issue}`);
@@ -30,7 +32,7 @@ export function printStackDiff(oldTemplate: any, newTemplate: cxapi.SynthesizedS
3032
}
3133

3234
if (!diff.isEmpty) {
33-
cfnDiff.formatDifferences(process.stderr, diff, buildLogicalToPathMap(newTemplate));
35+
cfnDiff.formatDifferences(process.stderr, diff, buildLogicalToPathMap(newTemplate), context);
3436
} else {
3537
print(colors.green('There were no differences'));
3638
}

0 commit comments

Comments
 (0)