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; } }