packages/@aws-cdk/toolkit-lib/lib/api/diff/diff-formatter.ts (225 lines of code) (raw):

import { format } from 'node:util'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import { formatDifferences, formatSecurityChanges, fullDiff, mangleLikeCloudFormation, type TemplateDiff, } from '@aws-cdk/cloudformation-diff'; import type * as cxapi from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; import { PermissionChangeType } from '../../payloads'; import type { NestedStackTemplates } from '../cloudformation'; import type { IoHelper } from '../io/private'; import { IoDefaultMessages } from '../io/private'; import { StringWriteStream } from '../streams'; /** * Output of formatSecurityDiff */ interface FormatSecurityDiffOutput { /** * Complete formatted security diff */ readonly formattedDiff: string; /** * The type of permission changes in the security diff. * The IoHost will use this to decide whether or not to print. */ readonly permissionChangeType: PermissionChangeType; } /** * Output of formatStackDiff */ interface FormatStackDiffOutput { /** * Number of stacks with diff changes */ readonly numStacksWithChanges: number; /** * Complete formatted diff */ readonly formattedDiff: string; } /** * Props for the Diff Formatter */ interface DiffFormatterProps { /** * Helper for the IoHost class */ readonly ioHelper: IoHelper; /** * The relevant information for the Template that is being diffed. * Includes the old/current state of the stack as well as the new state. */ readonly templateInfo: TemplateInfo; } /** * PRoperties specific to formatting the stack diff */ interface FormatStackDiffOptions { /** * do not filter out AWS::CDK::Metadata or Rules * * @default false */ readonly strict?: boolean; /** * lines of context to use in arbitrary JSON diff * * @default 3 */ readonly context?: number; /** * silences \'There were no differences\' messages * * @default false */ readonly quiet?: boolean; } interface ReusableStackDiffOptions extends FormatStackDiffOptions { readonly ioDefaultHelper: IoDefaultMessages; } /** * Information on a template's old/new state * that is used for diff. */ export interface TemplateInfo { /** * The old/existing template */ readonly oldTemplate: any; /** * The new template */ readonly newTemplate: cxapi.CloudFormationStackArtifact; /** * A CloudFormation ChangeSet to help the diff operation. * Probably created via `createDiffChangeSet`. * * @default undefined */ readonly changeSet?: any; /** * Whether or not there are any imported resources * * @default false */ readonly isImport?: boolean; /** * Any nested stacks included in the template * * @default {} */ readonly nestedStacks?: { [nestedStackLogicalId: string]: NestedStackTemplates; }; } /** * Class for formatting the diff output */ export class DiffFormatter { private readonly ioHelper: IoHelper; private readonly oldTemplate: any; private readonly newTemplate: cxapi.CloudFormationStackArtifact; private readonly stackName: string; private readonly changeSet?: any; private readonly nestedStacks: { [nestedStackLogicalId: string]: NestedStackTemplates } | undefined; private readonly isImport: boolean; /** * Stores the TemplateDiffs that get calculated in this DiffFormatter, * indexed by the stack name. */ private _diffs: { [name: string]: TemplateDiff } = {}; constructor(props: DiffFormatterProps) { this.ioHelper = props.ioHelper; this.oldTemplate = props.templateInfo.oldTemplate; this.newTemplate = props.templateInfo.newTemplate; this.stackName = props.templateInfo.newTemplate.stackName; this.changeSet = props.templateInfo.changeSet; this.nestedStacks = props.templateInfo.nestedStacks; this.isImport = props.templateInfo.isImport ?? false; } public get diffs() { return this._diffs; } /** * Get or creates the diff of a stack. * If it creates the diff, it stores the result in a map for * easier retreval later. */ private diff(stackName?: string, oldTemplate?: any) { const realStackName = stackName ?? this.stackName; if (!this._diffs[realStackName]) { this._diffs[realStackName] = fullDiff( oldTemplate ?? this.oldTemplate, this.newTemplate.template, this.changeSet, this.isImport, ); } return this._diffs[realStackName]; } /** * Return whether the diff has security-impacting changes that need confirmation. * * If no stackName is given, then the root stack name is used. */ private permissionType(stackName?: string): PermissionChangeType { const diff = this.diff(stackName); if (diff.permissionsBroadened) { return PermissionChangeType.BROADENING; } else if (diff.permissionsAnyChanges) { return PermissionChangeType.NON_BROADENING; } else { return PermissionChangeType.NONE; } } /** * Format the stack diff */ public formatStackDiff(options: FormatStackDiffOptions = {}): FormatStackDiffOutput { const ioDefaultHelper = new IoDefaultMessages(this.ioHelper); return this.formatStackDiffHelper( this.oldTemplate, this.stackName, this.nestedStacks, { ...options, ioDefaultHelper, }, ); } private formatStackDiffHelper( oldTemplate: any, stackName: string, nestedStackTemplates: { [nestedStackLogicalId: string]: NestedStackTemplates } | undefined, options: ReusableStackDiffOptions, ) { let diff = this.diff(stackName, oldTemplate); // The stack diff is formatted via `Formatter`, which takes in a stream // and sends its output directly to that stream. To faciliate use of the // global CliIoHost, we create our own stream to capture the output of // `Formatter` and return the output as a string for the consumer of // `formatStackDiff` to decide what to do with it. const stream = new StringWriteStream(); let numStacksWithChanges = 0; let formattedDiff = ''; let filteredChangesCount = 0; try { // must output the stack name if there are differences, even if quiet if (stackName && (!options.quiet || !diff.isEmpty)) { stream.write(format(`Stack ${chalk.bold(stackName)}\n`)); } if (!options.quiet && this.isImport) { stream.write('Parameters and rules created during migration do not affect resource configuration.\n'); } // detect and filter out mangled characters from the diff if (diff.differenceCount && !options.strict) { const mangledNewTemplate = JSON.parse(mangleLikeCloudFormation(JSON.stringify(this.newTemplate.template))); const mangledDiff = fullDiff(this.oldTemplate, mangledNewTemplate, this.changeSet); filteredChangesCount = Math.max(0, diff.differenceCount - mangledDiff.differenceCount); if (filteredChangesCount > 0) { diff = mangledDiff; } } // filter out 'AWS::CDK::Metadata' resources from the template // filter out 'CheckBootstrapVersion' rules from the template if (!options.strict) { obscureDiff(diff); } if (!diff.isEmpty) { numStacksWithChanges++; // formatDifferences updates the stream with the formatted stack diff formatDifferences(stream, diff, { ...logicalIdMapFromTemplate(this.oldTemplate), ...buildLogicalToPathMap(this.newTemplate), }, options.context); } else if (!options.quiet) { stream.write(chalk.green('There were no differences\n')); } if (filteredChangesCount > 0) { stream.write(chalk.yellow(`Omitted ${filteredChangesCount} changes because they are likely mangled non-ASCII characters. Use --strict to print them.\n`)); } } finally { // store the stream containing a formatted stack diff formattedDiff = stream.toString(); stream.end(); } for (const nestedStackLogicalId of Object.keys(nestedStackTemplates ?? {})) { if (!nestedStackTemplates) { break; } const nestedStack = nestedStackTemplates[nestedStackLogicalId]; (this.newTemplate as any)._template = nestedStack.generatedTemplate; const nextDiff = this.formatStackDiffHelper( nestedStack.deployedTemplate, nestedStack.physicalName ?? nestedStackLogicalId, nestedStack.nestedStackTemplates, options, ); numStacksWithChanges += nextDiff.numStacksWithChanges; formattedDiff += nextDiff.formattedDiff; } return { numStacksWithChanges, formattedDiff, }; } /** * Format the security diff */ public formatSecurityDiff(): FormatSecurityDiffOutput { const diff = this.diff(); // The security diff is formatted via `Formatter`, which takes in a stream // and sends its output directly to that stream. To faciliate use of the // global CliIoHost, we create our own stream to capture the output of // `Formatter` and return the output as a string for the consumer of // `formatSecurityDiff` to decide what to do with it. const stream = new StringWriteStream(); stream.write(format(`Stack ${chalk.bold(this.stackName)}\n`)); try { // formatSecurityChanges updates the stream with the formatted security diff formatSecurityChanges(stream, diff, buildLogicalToPathMap(this.newTemplate)); } finally { stream.end(); } // store the stream containing a formatted stack diff const formattedDiff = stream.toString(); return { formattedDiff, permissionChangeType: this.permissionType() }; } } function buildLogicalToPathMap(stack: cxapi.CloudFormationStackArtifact) { const map: { [id: string]: string } = {}; for (const md of stack.findMetadataByType(cxschema.ArtifactMetadataEntryType.LOGICAL_ID)) { map[md.data as string] = md.path; } return map; } function logicalIdMapFromTemplate(template: any) { const ret: Record<string, string> = {}; for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) { const path = (resource as any)?.Metadata?.['aws:cdk:path']; if (path) { ret[logicalId] = path; } } return ret; } /** * Remove any template elements that we don't want to show users. * This is currently: * - AWS::CDK::Metadata resource * - CheckBootstrapVersion Rule */ function obscureDiff(diff: TemplateDiff) { if (diff.unknown) { // see https://github.com/aws/aws-cdk/issues/17942 diff.unknown = diff.unknown.filter(change => { if (!change) { return true; } if (change.newValue?.CheckBootstrapVersion) { return false; } if (change.oldValue?.CheckBootstrapVersion) { return false; } return true; }); } if (diff.resources) { diff.resources = diff.resources.filter(change => { if (!change) { return true; } if (change.newResourceType === 'AWS::CDK::Metadata') { return false; } if (change.oldResourceType === 'AWS::CDK::Metadata') { return false; } return true; }); } }