packages/@aws-cdk/cloudformation-diff/lib/format.ts (363 lines of code) (raw):
import { format } from 'util';
import * as chalk from 'chalk';
import type { DifferenceCollection, TemplateDiff } from './diff/types';
import { deepEqual } from './diff/util';
import type { Difference, ResourceDifference } from './diff-template';
import { isPropertyDifference, ResourceImpact } from './diff-template';
import { formatTable } from './format-table';
import type { IamChanges } from './iam/iam-changes';
import type { SecurityGroupChanges } from './network/security-group-changes';
// from cx-api
const PATH_METADATA_KEY = 'aws:cdk:path';
/* eslint-disable @typescript-eslint/no-require-imports */
const { structuredPatch } = require('diff');
/* eslint-enable */
export interface FormatStream extends NodeJS.WritableStream {
columns?: number;
}
/**
* Renders template differences to the process' console.
*
* @param stream The IO stream where to output the rendered diff.
* @param templateDiff TemplateDiff to be rendered to the console.
* @param logicalToPathMap A map from logical ID to construct path. Useful in
* 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: FormatStream,
templateDiff: TemplateDiff,
logicalToPathMap: { [logicalId: string]: string } = {},
context: number = 3) {
const formatter = new Formatter(stream, logicalToPathMap, templateDiff, context);
if (templateDiff.awsTemplateFormatVersion || templateDiff.transform || templateDiff.description) {
formatter.printSectionHeader('Template');
formatter.formatDifference('AWSTemplateFormatVersion', 'AWSTemplateFormatVersion', templateDiff.awsTemplateFormatVersion);
formatter.formatDifference('Transform', 'Transform', templateDiff.transform);
formatter.formatDifference('Description', 'Description', templateDiff.description);
formatter.printSectionFooter();
}
formatSecurityChangesWithBanner(formatter, templateDiff);
formatter.formatSection('Parameters', 'Parameter', templateDiff.parameters);
formatter.formatSection('Metadata', 'Metadata', templateDiff.metadata);
formatter.formatSection('Mappings', 'Mapping', templateDiff.mappings);
formatter.formatSection('Conditions', 'Condition', templateDiff.conditions);
formatter.formatSection('Resources', 'Resource', templateDiff.resources, formatter.formatResourceDifference.bind(formatter));
formatter.formatSection('Outputs', 'Output', templateDiff.outputs);
formatter.formatSection('Other Changes', 'Unknown', templateDiff.unknown);
}
/**
* Renders a diff of security changes to the given stream
*/
export function formatSecurityChanges(
stream: NodeJS.WritableStream,
templateDiff: TemplateDiff,
logicalToPathMap: { [logicalId: string]: string } = {},
context?: number) {
const formatter = new Formatter(stream, logicalToPathMap, templateDiff, context);
formatSecurityChangesWithBanner(formatter, templateDiff);
}
function formatSecurityChangesWithBanner(formatter: Formatter, templateDiff: TemplateDiff) {
if (!templateDiff.iamChanges.hasChanges && !templateDiff.securityGroupChanges.hasChanges) {
return;
}
formatter.formatIamChanges(templateDiff.iamChanges);
formatter.formatSecurityGroupChanges(templateDiff.securityGroupChanges);
formatter.warning('(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)');
formatter.printSectionFooter();
}
const ADDITION = chalk.green('[+]');
const CONTEXT = chalk.grey('[ ]');
const UPDATE = chalk.yellow('[~]');
const REMOVAL = chalk.red('[-]');
const IMPORT = chalk.blue('[←]');
export class Formatter {
constructor(
private readonly stream: FormatStream,
private readonly logicalToPathMap: { [logicalId: string]: string },
diff?: TemplateDiff,
private readonly context: number = 3) {
// Read additional construct paths from the diff if it is supplied
if (diff) {
this.readConstructPathsFrom(diff);
}
}
public print(fmt: string, ...args: any[]) {
this.stream.write(chalk.white(format(fmt, ...args)) + '\n');
}
public warning(fmt: string, ...args: any[]) {
this.stream.write(chalk.yellow(format(fmt, ...args)) + '\n');
}
public formatSection<V, T extends Difference<V>>(
title: string,
entryType: string,
collection: DifferenceCollection<V, T>,
formatter: (type: string, id: string, diff: T) => void = this.formatDifference.bind(this)) {
if (collection.differenceCount === 0) {
return;
}
this.printSectionHeader(title);
collection.forEachDifference((id, diff) => formatter(entryType, id, diff));
this.printSectionFooter();
}
public printSectionHeader(title: string) {
this.print(chalk.underline(chalk.bold(title)));
}
public printSectionFooter() {
this.print('');
}
/**
* Print a simple difference for a given named entity.
*
* @param logicalId the name of the entity that is different.
* @param diff the difference to be rendered.
*/
public formatDifference(type: string, logicalId: string, diff: Difference<any> | undefined) {
if (!diff || !diff.isDifferent) {
return;
}
let value;
const oldValue = this.formatValue(diff.oldValue, chalk.red);
const newValue = this.formatValue(diff.newValue, chalk.green);
if (diff.isAddition) {
value = newValue;
} else if (diff.isUpdate) {
value = `${oldValue} to ${newValue}`;
} else if (diff.isRemoval) {
value = oldValue;
}
this.print(`${this.formatPrefix(diff)} ${chalk.cyan(type)} ${this.formatLogicalId(logicalId)}: ${value}`);
}
/**
* Print a resource difference for a given logical ID.
*
* @param logicalId the logical ID of the resource that changed.
* @param diff the change to be rendered.
*/
public formatResourceDifference(_type: string, logicalId: string, diff: ResourceDifference) {
if (!diff.isDifferent) {
return;
}
const resourceType = diff.isRemoval ? diff.oldResourceType : diff.newResourceType;
// eslint-disable-next-line max-len
this.print(`${this.formatResourcePrefix(diff)} ${this.formatValue(resourceType, chalk.cyan)} ${this.formatLogicalId(logicalId)} ${this.formatImpact(diff.changeImpact)}`.trimEnd());
if (diff.isUpdate) {
const differenceCount = diff.differenceCount;
let processedCount = 0;
diff.forEachDifference((_, name, values) => {
processedCount += 1;
this.formatTreeDiff(name, values, processedCount === differenceCount);
});
}
}
public formatResourcePrefix(diff: ResourceDifference) {
if (diff.isImport) {
return IMPORT;
}
return this.formatPrefix(diff);
}
public formatPrefix<T>(diff: Difference<T>) {
if (diff.isAddition) {
return ADDITION;
}
if (diff.isUpdate) {
return UPDATE;
}
if (diff.isRemoval) {
return REMOVAL;
}
return chalk.white('[?]');
}
/**
* @param value the value to be formatted.
* @param color the color to be used.
*
* @returns the formatted string, with color applied.
*/
public formatValue(value: any, color: (str: string) => string) {
if (value == null) {
return undefined;
}
if (typeof value === 'string') {
return color(value);
}
return color(JSON.stringify(value));
}
/**
* @param impact the impact to be formatted
* @returns a user-friendly, colored string representing the impact.
*/
public formatImpact(impact: ResourceImpact) {
switch (impact) {
case ResourceImpact.MAY_REPLACE:
return chalk.italic(chalk.yellow('may be replaced'));
case ResourceImpact.WILL_REPLACE:
return chalk.italic(chalk.bold(chalk.red('replace')));
case ResourceImpact.WILL_DESTROY:
return chalk.italic(chalk.bold(chalk.red('destroy')));
case ResourceImpact.WILL_ORPHAN:
return chalk.italic(chalk.yellow('orphan'));
case ResourceImpact.WILL_IMPORT:
return chalk.italic(chalk.blue('import'));
case ResourceImpact.WILL_UPDATE:
case ResourceImpact.WILL_CREATE:
case ResourceImpact.NO_CHANGE:
return ''; // no extra info is gained here
}
}
/**
* Renders a tree of differences under a particular name.
* @param name the name of the root of the tree.
* @param diff the difference on the tree.
* @param last whether this is the last node of a parent tree.
*/
public formatTreeDiff(name: string, diff: Difference<any>, last: boolean) {
let additionalInfo = '';
if (isPropertyDifference(diff)) {
if (diff.changeImpact === ResourceImpact.MAY_REPLACE) {
additionalInfo = ' (may cause replacement)';
} else if (diff.changeImpact === ResourceImpact.WILL_REPLACE) {
additionalInfo = ' (requires replacement)';
}
}
this.print(' %s─ %s %s%s', last ? '└' : '├', this.changeTag(diff.oldValue, diff.newValue), name, additionalInfo);
return this.formatObjectDiff(diff.oldValue, diff.newValue, ` ${last ? ' ' : '│'}`);
}
/**
* Renders the difference between two objects, looking for the differences as deep as possible,
* and rendering a tree graph of the path until the difference is found.
*
* @param oldObject the old object.
* @param newObject the new object.
* @param linePrefix a prefix (indent-like) to be used on every line.
*/
public formatObjectDiff(oldObject: any, newObject: any, linePrefix: string) {
if ((typeof oldObject !== typeof newObject) || Array.isArray(oldObject) || typeof oldObject === 'string' || typeof oldObject === 'number') {
if (oldObject !== undefined && newObject !== undefined) {
if (typeof oldObject === 'object' || typeof newObject === 'object') {
const oldStr = JSON.stringify(oldObject, null, 2);
const newStr = JSON.stringify(newObject, null, 2);
const diff = _diffStrings(oldStr, newStr, this.context);
for (let i = 0; i < diff.length; i++) {
this.print('%s %s %s', linePrefix, i === 0 ? '└─' : ' ', diff[i]);
}
} else {
this.print('%s ├─ %s %s', linePrefix, REMOVAL, this.formatValue(oldObject, chalk.red));
this.print('%s └─ %s %s', linePrefix, ADDITION, this.formatValue(newObject, chalk.green));
}
} else if (oldObject !== undefined /* && newObject === undefined */) {
this.print('%s └─ %s', linePrefix, this.formatValue(oldObject, chalk.red));
} else /* if (oldObject === undefined && newObject !== undefined) */ {
this.print('%s └─ %s', linePrefix, this.formatValue(newObject, chalk.green));
}
return;
}
const keySet = new Set(Object.keys(oldObject));
Object.keys(newObject).forEach(k => keySet.add(k));
const keys = new Array(...keySet).filter(k => !deepEqual(oldObject[k], newObject[k])).sort();
const lastKey = keys[keys.length - 1];
for (const key of keys) {
const oldValue = oldObject[key];
const newValue = newObject[key];
const treePrefix = key === lastKey ? '└' : '├';
if (oldValue !== undefined && newValue !== undefined) {
this.print('%s %s─ %s %s:', linePrefix, treePrefix, this.changeTag(oldValue, newValue), chalk.blue(`.${key}`));
this.formatObjectDiff(oldValue, newValue, `${linePrefix} ${key === lastKey ? ' ' : '│'}`);
} else if (oldValue !== undefined /* && newValue === undefined */) {
this.print('%s %s─ %s Removed: %s', linePrefix, treePrefix, REMOVAL, chalk.blue(`.${key}`));
} else /* if (oldValue === undefined && newValue !== undefined */ {
this.print('%s %s─ %s Added: %s', linePrefix, treePrefix, ADDITION, chalk.blue(`.${key}`));
}
}
}
/**
* @param oldValue the old value of a difference.
* @param newValue the new value of a difference.
*
* @returns a tag to be rendered in the diff, reflecting whether the difference
* was an ADDITION, UPDATE or REMOVAL.
*/
public changeTag(oldValue: any | undefined, newValue: any | undefined): string {
if (oldValue !== undefined && newValue !== undefined) {
return UPDATE;
} else if (oldValue !== undefined /* && newValue === undefined*/) {
return REMOVAL;
} else /* if (oldValue === undefined && newValue !== undefined) */ {
return ADDITION;
}
}
/**
* Find 'aws:cdk:path' metadata in the diff and add it to the logicalToPathMap
*
* There are multiple sources of logicalID -> path mappings: synth metadata
* and resource metadata, and we combine all sources into a single map.
*/
public readConstructPathsFrom(templateDiff: TemplateDiff) {
for (const [logicalId, resourceDiff] of Object.entries(templateDiff.resources)) {
if (!resourceDiff) {
continue;
}
const oldPathMetadata = resourceDiff.oldValue?.Metadata?.[PATH_METADATA_KEY];
if (oldPathMetadata && !(logicalId in this.logicalToPathMap)) {
this.logicalToPathMap[logicalId] = oldPathMetadata;
}
const newPathMetadata = resourceDiff.newValue?.Metadata?.[PATH_METADATA_KEY];
if (newPathMetadata && !(logicalId in this.logicalToPathMap)) {
this.logicalToPathMap[logicalId] = newPathMetadata;
}
}
}
public formatLogicalId(logicalId: string) {
// if we have a path in the map, return it
const normalized = this.normalizedLogicalIdPath(logicalId);
if (normalized) {
return `${normalized} ${chalk.gray(logicalId)}`;
}
return logicalId;
}
public normalizedLogicalIdPath(logicalId: string): string | undefined {
// if we have a path in the map, return it
const path = this.logicalToPathMap[logicalId];
return path ? normalizePath(path) : undefined;
/**
* Path is supposed to start with "/stack-name". If this is the case (i.e. path has more than
* two components, we remove the first part. Otherwise, we just use the full path.
*/
function normalizePath(p: string) {
if (p.startsWith('/')) {
p = p.slice(1);
}
let parts = p.split('/');
if (parts.length > 1) {
parts = parts.slice(1);
// remove the last component if it's "Resource" or "Default" (if we have more than a single component)
if (parts.length > 1) {
const last = parts[parts.length - 1];
if (last === 'Resource' || last === 'Default') {
parts = parts.slice(0, parts.length - 1);
}
}
p = parts.join('/');
}
return p;
}
}
public formatIamChanges(changes: IamChanges) {
if (!changes.hasChanges) {
return;
}
if (changes.statements.hasChanges) {
this.printSectionHeader('IAM Statement Changes');
this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarizeStatements()), this.stream.columns));
}
if (changes.managedPolicies.hasChanges) {
this.printSectionHeader('IAM Policy Changes');
this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarizeManagedPolicies()), this.stream.columns));
}
if (changes.ssoPermissionSets.hasChanges || changes.ssoInstanceACAConfigs.hasChanges || changes.ssoAssignments.hasChanges) {
this.printSectionHeader('IAM Identity Center Changes');
if (changes.ssoPermissionSets.hasChanges) {
this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarizeSsoPermissionSets()), this.stream.columns));
}
if (changes.ssoInstanceACAConfigs.hasChanges) {
this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarizeSsoInstanceACAConfigs()), this.stream.columns));
}
if (changes.ssoAssignments.hasChanges) {
this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarizeSsoAssignments()), this.stream.columns));
}
}
}
public formatSecurityGroupChanges(changes: SecurityGroupChanges) {
if (!changes.hasChanges) {
return;
}
this.printSectionHeader('Security Group Changes');
this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarize()), this.stream.columns));
}
public deepSubstituteBracedLogicalIds(rows: string[][]): string[][] {
return rows.map(row => row.map(this.substituteBracedLogicalIds.bind(this)));
}
/**
* Substitute all strings like ${LogId.xxx} with the path instead of the logical ID
*/
public substituteBracedLogicalIds(source: string): string {
return source.replace(/\$\{([^.}]+)(.[^}]+)?\}/ig, (_match, logId, suffix) => {
return '${' + (this.normalizedLogicalIdPath(logId) || logId) + (suffix || '') + '}';
});
}
}
/**
* A patch as returned by ``diff.structuredPatch``.
*/
interface Patch {
/**
* Hunks in the patch.
*/
hunks: ReadonlyArray<PatchHunk>;
}
/**
* A hunk in a patch produced by ``diff.structuredPatch``.
*/
interface PatchHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
/**
* Creates a unified diff of two strings.
*
* @param oldStr the "old" version of the string.
* @param newStr the "new" version of the string.
* @param context the number of context lines to use in arbitrary JSON diff.
*
* @returns an array of diff lines.
*/
function _diffStrings(oldStr: string, newStr: string, context: number): string[] {
const patch: Patch = structuredPatch(null, null, oldStr, newStr, null, null, { context });
const result = new Array<string>();
for (const hunk of patch.hunks) {
result.push(chalk.magenta(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`));
const baseIndent = _findIndent(hunk.lines);
for (const line of hunk.lines) {
// Don't care about termination newline.
if (line === '\\ No newline at end of file') {
continue;
}
const marker = line.charAt(0);
const text = line.slice(1 + baseIndent);
switch (marker) {
case ' ':
result.push(`${CONTEXT} ${text}`);
break;
case '+':
result.push(chalk.bold(`${ADDITION} ${chalk.green(text)}`));
break;
case '-':
result.push(chalk.bold(`${REMOVAL} ${chalk.red(text)}`));
break;
default:
throw new Error(`Unexpected diff marker: ${marker} (full line: ${line})`);
}
}
}
return result;
function _findIndent(lines: string[]): number {
let indent = Number.MAX_SAFE_INTEGER;
for (const line of lines) {
for (let i = 1; i < line.length; i++) {
if (line.charAt(i) !== ' ') {
indent = indent > i - 1 ? i - 1 : indent;
break;
}
}
}
return indent;
}
}