diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff-formatter.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff-formatter.ts new file mode 100644 index 000000000..569d24d32 --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff-formatter.ts @@ -0,0 +1,386 @@ +import { format } from 'node:util'; +import { Writable } from 'stream'; +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import { + type DescribeChangeSetOutput, + type TemplateDiff, + fullDiff, + formatSecurityChanges, + formatDifferences, + mangleLikeCloudFormation, +} from '@aws-cdk/cloudformation-diff'; +import type * as cxapi from '@aws-cdk/cx-api'; +import * as chalk from 'chalk'; + +import type { NestedStackTemplates } from '../cloudformation/nested-stack-templates'; +import type { IoHelper } from '../io/private'; +import { IoDefaultMessages } from '../io/private'; +import { RequireApproval } from '../require-approval'; +import { ToolkitError } from '../toolkit-error'; + +/* + * Custom writable stream that collects text into a string buffer. + * Used on classes that take in and directly write to a stream, but + * we intend to capture the output rather than print. + */ +class StringWriteStream extends Writable { + private buffer: string[] = []; + + constructor() { + super(); + } + + _write(chunk: any, _encoding: string, callback: (error?: Error | null) => void): void { + this.buffer.push(chunk.toString()); + callback(); + } + + toString(): string { + return this.buffer.join(''); + } +} + +/** + * Output of formatSecurityDiff + */ +export interface FormatSecurityDiffOutput { + /** + * Complete formatted security diff, if it is prompt-worthy + */ + readonly formattedDiff?: string; +} + +/** + * Output of formatStackDiff + */ +export interface FormatStackDiffOutput { + /** + * Number of stacks with diff changes + */ + readonly numStacksWithChanges: number; + + /** + * Complete formatted diff + */ + readonly formattedDiff: string; +} + +/** + * Props for the Diff Formatter + */ +export interface DiffFormatterProps { + /** + * Helper for the IoHost class + */ + readonly ioHelper: IoHelper; + + /** + * The old/current state of the stack. + */ + readonly oldTemplate: any; + + /** + * The new/target state of the stack. + */ + readonly newTemplate: cxapi.CloudFormationStackArtifact; +} + +/** + * Properties specific to formatting the security diff + */ +export interface FormatSecurityDiffOptions { + /** + * The approval level of the security diff + */ + readonly requireApproval: RequireApproval; + + /** + * The name of the Stack. + */ + readonly stackName?: string; + + /** + * The changeSet for the Stack. + * + * @default undefined + */ + readonly changeSet?: DescribeChangeSetOutput; +} + +/** + * PRoperties specific to formatting the stack diff + */ +export 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; + + /** + * The name of the stack + */ + readonly stackName?: string; + + /** + * @default undefined + */ + readonly changeSet?: DescribeChangeSetOutput; + + /** + * @default false + */ + readonly isImport?: boolean; + + /** + * @default undefined + */ + readonly nestedStackTemplates?: { [nestedStackLogicalId: string]: NestedStackTemplates }; +} + +interface ReusableStackDiffOptions extends Omit { + readonly ioDefaultHelper: IoDefaultMessages; +} + +/** + * Class for formatting the diff output + */ +export class DiffFormatter { + private readonly ioHelper: IoHelper; + private readonly oldTemplate: any; + private readonly newTemplate: cxapi.CloudFormationStackArtifact; + + constructor(props: DiffFormatterProps) { + this.ioHelper = props.ioHelper; + this.oldTemplate = props.oldTemplate; + this.newTemplate = props.newTemplate; + } + + /** + * Format the stack diff + */ + public formatStackDiff(options: FormatStackDiffOptions): FormatStackDiffOutput { + const ioDefaultHelper = new IoDefaultMessages(this.ioHelper); + return this.formatStackDiffHelper( + this.oldTemplate, + options.stackName, + options.nestedStackTemplates, + { + ...options, + ioDefaultHelper, + }, + ); + } + + private formatStackDiffHelper( + oldTemplate: any, + stackName: string | undefined, + nestedStackTemplates: { [nestedStackLogicalId: string]: NestedStackTemplates } | undefined, + options: ReusableStackDiffOptions, + ) { + let diff = fullDiff(oldTemplate, this.newTemplate.template, options.changeSet, options.isImport); + + // 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 %s\n', chalk.bold(stackName))); + } + + if (!options.quiet && options.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, options.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); + + // store the stream containing a formatted stack diff + formattedDiff = stream.toString(); + } else if (!options.quiet) { + options.ioDefaultHelper.info(chalk.green('There were no differences')); + } + } finally { + stream.end(); + } + + if (filteredChangesCount > 0) { + options.ioDefaultHelper.info(chalk.yellow(`Omitted ${filteredChangesCount} changes because they are likely mangled non-ASCII characters. Use --strict to print them.`)); + } + + 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(options: FormatSecurityDiffOptions): FormatSecurityDiffOutput { + const ioDefaultHelper = new IoDefaultMessages(this.ioHelper); + + const diff = fullDiff(this.oldTemplate, this.newTemplate.template, options.changeSet); + + if (diffRequiresApproval(diff, options.requireApproval)) { + ioDefaultHelper.info(format('Stack %s\n', chalk.bold(options.stackName))); + + // eslint-disable-next-line max-len + ioDefaultHelper.warning(`This deployment will make potentially sensitive changes according to your current security approval level (--require-approval ${options.requireApproval}).`); + ioDefaultHelper.warning('Please confirm you intend to make the following modifications:\n'); + + // 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(); + 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 }; + } + return {}; + } +} + +/** + * Return whether the diff has security-impacting changes that need confirmation + * + * TODO: Filter the security impact determination based off of an enum that allows + * us to pick minimum "severities" to alert on. + */ +function diffRequiresApproval(diff: TemplateDiff, requireApproval: RequireApproval) { + switch (requireApproval) { + case RequireApproval.NEVER: return false; + case RequireApproval.ANY_CHANGE: return diff.permissionsAnyChanges; + case RequireApproval.BROADENING: return diff.permissionsBroadened; + default: throw new ToolkitError(`Unrecognized approval level: ${requireApproval}`); + } +} + +export 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; +} + +export function logicalIdMapFromTemplate(template: any) { + const ret: Record = {}; + + 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 + */ +export 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; + }); + } +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff.ts deleted file mode 100644 index ab69da2ff..000000000 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { format } from 'node:util'; -import { Writable } from 'stream'; -import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import { - type DescribeChangeSetOutput, - type TemplateDiff, - fullDiff, - formatSecurityChanges, - formatDifferences, - mangleLikeCloudFormation, -} from '@aws-cdk/cloudformation-diff'; -import type * as cxapi from '@aws-cdk/cx-api'; -import * as chalk from 'chalk'; - -import { RequireApproval } from '../../api/require-approval'; -import { ToolkitError } from '../../api/toolkit-error'; -import type { NestedStackTemplates } from '../cloudformation/nested-stack-templates'; -import type { IoHelper } from '../io/private'; -import { IoDefaultMessages } from '../io/private'; - -/* - * Custom writable stream that collects text into a string buffer. - * Used on classes that take in and directly write to a stream, but - * we intend to capture the output rather than print. - */ -export class StringWriteStream extends Writable { - private buffer: string[] = []; - - constructor() { - super(); - } - - _write(chunk: any, _encoding: string, callback: (error?: Error | null) => void): void { - this.buffer.push(chunk.toString()); - callback(); - } - - toString(): string { - return this.buffer.join(''); - } -} - -/** - * Output of formatSecurityDiff - */ -export interface FormatSecurityDiffOutput { - /** - * Complete formatted security diff, if it is prompt-worthy - */ - readonly formattedDiff?: string; -} - -/** - * Formats the security changes of this diff, if the change is impactful enough according to the approval level - * - * Returns the diff if the changes are prompt-worthy, an empty object otherwise. - */ -export function formatSecurityDiff( - ioHelper: IoHelper, - oldTemplate: any, - newTemplate: cxapi.CloudFormationStackArtifact, - requireApproval: RequireApproval, - stackName?: string, - changeSet?: DescribeChangeSetOutput, -): FormatSecurityDiffOutput { - const ioDefaultHelper = new IoDefaultMessages(ioHelper); - - const diff = fullDiff(oldTemplate, newTemplate.template, changeSet); - - if (diffRequiresApproval(diff, requireApproval)) { - ioDefaultHelper.info(format('Stack %s\n', chalk.bold(stackName))); - - // eslint-disable-next-line max-len - ioDefaultHelper.warning(`This deployment will make potentially sensitive changes according to your current security approval level (--require-approval ${requireApproval}).`); - ioDefaultHelper.warning('Please confirm you intend to make the following modifications:\n'); - - // 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(); - try { - // formatSecurityChanges updates the stream with the formatted security diff - formatSecurityChanges(stream, diff, buildLogicalToPathMap(newTemplate)); - } finally { - stream.end(); - } - // store the stream containing a formatted stack diff - const formattedDiff = stream.toString(); - return { formattedDiff }; - } - return {}; -} - -/** - * Output of formatStackDiff - */ -export interface FormatStackDiffOutput { - /** - * Number of stacks with diff changes - */ - readonly numStacksWithChanges: number; - - /** - * Complete formatted diff - */ - readonly formattedDiff: string; -} - -/** - * Formats the differences between two template states and returns it as a string. - * - * @param oldTemplate the old/current state of the stack. - * @param newTemplate the new/target state of the stack. - * @param strict do not filter out AWS::CDK::Metadata or Rules - * @param context lines of context to use in arbitrary JSON diff - * @param quiet silences \'There were no differences\' messages - * - * @returns the formatted diff, and the number of stacks in this stack tree that have differences, including the top-level root stack - */ -export function formatStackDiff( - ioHelper: IoHelper, - oldTemplate: any, - newTemplate: cxapi.CloudFormationStackArtifact, - strict: boolean, - context: number, - quiet: boolean, - stackName?: string, - changeSet?: DescribeChangeSetOutput, - isImport?: boolean, - nestedStackTemplates?: { [nestedStackLogicalId: string]: NestedStackTemplates }): FormatStackDiffOutput { - const ioDefaultHelper = new IoDefaultMessages(ioHelper); - - let diff = fullDiff(oldTemplate, newTemplate.template, changeSet, isImport); - - // 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 && (!quiet || !diff.isEmpty)) { - stream.write(format('Stack %s\n', chalk.bold(stackName))); - } - - if (!quiet && 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 && !strict) { - const mangledNewTemplate = JSON.parse(mangleLikeCloudFormation(JSON.stringify(newTemplate.template))); - const mangledDiff = fullDiff(oldTemplate, mangledNewTemplate, 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 (!strict) { - obscureDiff(diff); - } - - if (!diff.isEmpty) { - numStacksWithChanges++; - - // formatDifferences updates the stream with the formatted stack diff - formatDifferences(stream, diff, { - ...logicalIdMapFromTemplate(oldTemplate), - ...buildLogicalToPathMap(newTemplate), - }, context); - - // store the stream containing a formatted stack diff - formattedDiff = stream.toString(); - } else if (!quiet) { - ioDefaultHelper.info(chalk.green('There were no differences')); - } - } finally { - stream.end(); - } - - if (filteredChangesCount > 0) { - ioDefaultHelper.info(chalk.yellow(`Omitted ${filteredChangesCount} changes because they are likely mangled non-ASCII characters. Use --strict to print them.`)); - } - - for (const nestedStackLogicalId of Object.keys(nestedStackTemplates ?? {})) { - if (!nestedStackTemplates) { - break; - } - const nestedStack = nestedStackTemplates[nestedStackLogicalId]; - - (newTemplate as any)._template = nestedStack.generatedTemplate; - const nextDiff = formatStackDiff( - ioHelper, - nestedStack.deployedTemplate, - newTemplate, - strict, - context, - quiet, - nestedStack.physicalName ?? nestedStackLogicalId, - undefined, - isImport, - nestedStack.nestedStackTemplates, - ); - numStacksWithChanges += nextDiff.numStacksWithChanges; - formattedDiff += nextDiff.formattedDiff; - } - - return { - numStacksWithChanges, - formattedDiff, - }; -} - -/** - * Return whether the diff has security-impacting changes that need confirmation - * - * TODO: Filter the security impact determination based off of an enum that allows - * us to pick minimum "severities" to alert on. - */ -function diffRequiresApproval(diff: TemplateDiff, requireApproval: RequireApproval) { - switch (requireApproval) { - case RequireApproval.NEVER: return false; - case RequireApproval.ANY_CHANGE: return diff.permissionsAnyChanges; - case RequireApproval.BROADENING: return diff.permissionsBroadened; - default: throw new ToolkitError(`Unrecognized approval level: ${requireApproval}`); - } -} - -export 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; -} - -export function logicalIdMapFromTemplate(template: any) { - const ret: Record = {}; - - 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 - */ -export 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; - }); - } -} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/index.ts index a89b256df..d05620ced 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/index.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/index.ts @@ -1 +1 @@ -export * from './diff'; +export * from './diff-formatter'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts index b5662b6fb..d8f893307 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts @@ -1,7 +1,6 @@ -import { fullDiff, formatSecurityChanges, formatDifferences, mangleLikeCloudFormation } from '@aws-cdk/cloudformation-diff'; import type * as cxapi from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; -import { formatSecurityDiff, formatStackDiff } from '../../../src/api/diff/diff'; +import { DiffFormatter } from '../../../src/api/diff/diff-formatter'; import { IoHelper, IoDefaultMessages } from '../../../src/api/io/private'; import { RequireApproval } from '../../../src/api/require-approval'; @@ -58,20 +57,22 @@ describe('formatStackDiff', () => { test('returns no changes when templates are identical', () => { // WHEN - const result = formatStackDiff( - mockIoHelper, - {}, - { + const formatter = new DiffFormatter({ + ioHelper: mockIoHelper, + oldTemplate: {}, + newTemplate: { template: {}, templateFile: 'template.json', stackName: 'test-stack', findMetadataByType: () => [], } as any, - false, - 3, - false, - 'test-stack', - ); + }); + const result = formatter.formatStackDiff({ + strict: false, + context: 3, + quiet: false, + stackName: 'test-stack', + }); // THEN expect(result.numStacksWithChanges).toBe(0); @@ -81,15 +82,14 @@ describe('formatStackDiff', () => { test('formats differences when changes exist', () => { // WHEN - const result = formatStackDiff( - mockIoHelper, - {}, - mockNewTemplate, - false, - 3, - false, - 'test-stack', - ); + const formatter = new DiffFormatter({ + ioHelper: mockIoHelper, + oldTemplate: {}, + newTemplate: mockNewTemplate, + }); + const result = formatter.formatStackDiff({ + stackName: 'test-stack', + }); // THEN expect(result.numStacksWithChanges).toBe(1); @@ -102,34 +102,64 @@ describe('formatStackDiff', () => { ); }); + test('formats differences with isImport', () => { + // WHEN + const formatter = new DiffFormatter({ + ioHelper: mockIoHelper, + oldTemplate: {}, + newTemplate: mockNewTemplate, + }); + const result = formatter.formatStackDiff({ + stackName: 'test-stack', + isImport: true, + }); + + // THEN + expect(result.numStacksWithChanges).toBe(1); + expect(result.formattedDiff).toBeDefined(); + const sanitizedDiff = result.formattedDiff!.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').trim(); + expect(sanitizedDiff).toBe( + 'Stack test-stack\n' + + 'Parameters and rules created during migration do not affect resource configuration.\n' + + 'Resources\n' + + '[←] AWS::Lambda::Function Func import', + ); + }); + test('handles nested stack templates', () => { + // GIVEN const nestedStackTemplates = { NestedStack1: { deployedTemplate: {}, generatedTemplate: {}, physicalName: 'nested-stack-1', - nestedStackTemplates: {}, + nestedStackTemplates: { + NestedStack2: { + deployedTemplate: {}, + generatedTemplate: {}, + physicalName: 'nested-stack-2', + nestedStackTemplates: {}, + }, + }, }, }; // WHEN - const result = formatStackDiff( - mockIoHelper, - {}, - mockNewTemplate, - false, - 3, - false, - 'test-stack', - undefined, - false, + const formatter = new DiffFormatter({ + ioHelper: mockIoHelper, + oldTemplate: {}, + newTemplate: mockNewTemplate, + }); + const result = formatter.formatStackDiff({ + stackName: 'test-stack', nestedStackTemplates, - ); + }); // THEN - expect(result.numStacksWithChanges).toBe(2); + expect(result.numStacksWithChanges).toBe(3); expect(result.formattedDiff).toContain(`Stack ${chalk.bold('test-stack')}`); expect(result.formattedDiff).toContain(`Stack ${chalk.bold('nested-stack-1')}`); + expect(result.formattedDiff).toContain(`Stack ${chalk.bold('nested-stack-2')}`); }); }); @@ -190,18 +220,20 @@ describe('formatSecurityDiff', () => { test('returns empty object when no security changes exist', () => { // WHEN - const result = formatSecurityDiff( - mockIoHelper, - {}, - { + const formatter = new DiffFormatter({ + ioHelper: mockIoHelper, + oldTemplate: {}, + newTemplate: { template: {}, templateFile: 'template.json', stackName: 'test-stack', findMetadataByType: () => [], } as any, - RequireApproval.BROADENING, - 'test-stack', - ); + }); + const result = formatter.formatSecurityDiff({ + stackName: 'test-stack', + requireApproval: RequireApproval.BROADENING, + }); // THEN expect(result).toEqual({}); @@ -210,13 +242,15 @@ describe('formatSecurityDiff', () => { test('formats diff when permissions are broadened and approval level is BROADENING', () => { // WHEN - const result = formatSecurityDiff( - mockIoHelper, - {}, - mockNewTemplate, - RequireApproval.BROADENING, - 'test-stack', - ); + const formatter = new DiffFormatter({ + ioHelper: mockIoHelper, + oldTemplate: {}, + newTemplate: mockNewTemplate, + }); + const result = formatter.formatSecurityDiff({ + stackName: 'test-stack', + requireApproval: RequireApproval.BROADENING, + }); // THEN expect(result.formattedDiff).toBeDefined(); @@ -240,13 +274,15 @@ describe('formatSecurityDiff', () => { test('formats diff for any security change when approval level is ANY_CHANGE', () => { // WHEN - const result = formatSecurityDiff( - mockIoHelper, - {}, - mockNewTemplate, - RequireApproval.ANY_CHANGE, - 'test-stack', - ); + const formatter = new DiffFormatter({ + ioHelper: mockIoHelper, + oldTemplate: {}, + newTemplate: mockNewTemplate, + }); + const result = formatter.formatSecurityDiff({ + stackName: 'test-stack', + requireApproval: RequireApproval.ANY_CHANGE, + }); // THEN expect(result.formattedDiff).toBeDefined(); @@ -273,13 +309,15 @@ describe('formatSecurityDiff', () => { test('returns empty object when approval level is NEVER', () => { // WHEN - const result = formatSecurityDiff( - mockIoHelper, - {}, - mockNewTemplate, - RequireApproval.NEVER, - 'test-stack', - ); + const formatter = new DiffFormatter({ + ioHelper: mockIoHelper, + oldTemplate: {}, + newTemplate: mockNewTemplate, + }); + const result = formatter.formatSecurityDiff({ + stackName: 'test-stack', + requireApproval: RequireApproval.NEVER, + }); // THEN expect(result).toEqual({}); diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 4fc6efbdd..b8eb06bff 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -36,7 +36,7 @@ import { tagsForStack, type Tag } from '../api/tags'; import type { AssetBuildNode, AssetPublishNode, Concurrency, StackNode, WorkGraph } from '../api/work-graph'; import { WorkGraphBuilder } from '../api/work-graph/work-graph-builder'; import { StackActivityProgress } from '../commands/deploy'; -import { formatSecurityDiff, formatStackDiff, RequireApproval } from '../commands/diff'; +import { DiffFormatter, RequireApproval } from '../commands/diff'; import { listStacks } from '../commands/list-stacks'; import type { FromScan, @@ -195,30 +195,26 @@ export class CdkToolkit { } const template = deserializeStructure(await fs.readFile(options.templatePath, { encoding: 'UTF-8' })); + const formatter = new DiffFormatter({ + ioHelper: asIoHelper(this.ioHost, 'diff'), + oldTemplate: template, + newTemplate: stacks.firstStack, + }); if (options.securityOnly) { - const securityDiff = formatSecurityDiff( - asIoHelper(this.ioHost, 'diff'), - template, - stacks.firstStack, - RequireApproval.BROADENING, - ); + const securityDiff = formatter.formatSecurityDiff({ + requireApproval: RequireApproval.BROADENING, + }); if (securityDiff.formattedDiff) { info(securityDiff.formattedDiff); diffs += 1; } } else { - const diff = formatStackDiff( - asIoHelper(this.ioHost, 'diff'), - template, - stacks.firstStack, + const diff = formatter.formatStackDiff({ strict, - contextLines, + context: contextLines, quiet, - undefined, - undefined, - false, - ); + }); diffs = diff.numStacksWithChanges; info(diff.formattedDiff); } @@ -231,6 +227,11 @@ export class CdkToolkit { ); const currentTemplate = templateWithNestedStacks.deployedRootTemplate; const nestedStacks = templateWithNestedStacks.nestedStacks; + const formatter = new DiffFormatter({ + ioHelper: asIoHelper(this.ioHost, 'diff'), + oldTemplate: currentTemplate, + newTemplate: stack, + }); const migrator = new ResourceMigrator({ deployments: this.props.deployments, @@ -279,31 +280,25 @@ export class CdkToolkit { } if (options.securityOnly) { - const securityDiff = formatSecurityDiff( - asIoHelper(this.ioHost, 'diff'), - currentTemplate, - stack, - RequireApproval.BROADENING, - stack.displayName, + const securityDiff = formatter.formatSecurityDiff({ + requireApproval: RequireApproval.BROADENING, + stackName: stack.displayName, changeSet, - ); + }); if (securityDiff.formattedDiff) { info(securityDiff.formattedDiff); diffs += 1; } } else { - const diff = formatStackDiff( - asIoHelper(this.ioHost, 'diff'), - currentTemplate, - stack, + const diff = formatter.formatStackDiff({ strict, - contextLines, + context: contextLines, quiet, - stack.displayName, + stackName: stack.displayName, changeSet, - !!resourcesToImport, - nestedStacks, - ); + isImport: !!resourcesToImport, + nestedStackTemplates: nestedStacks, + }); info(diff.formattedDiff); diffs += diff.numStacksWithChanges; } @@ -427,12 +422,14 @@ export class CdkToolkit { if (requireApproval !== RequireApproval.NEVER) { const currentTemplate = await this.props.deployments.readCurrentTemplate(stack); - const securityDiff = formatSecurityDiff( - asIoHelper(this.ioHost, 'deploy'), - currentTemplate, - stack, + const formatter = new DiffFormatter({ + ioHelper: asIoHelper(this.ioHost, 'deploy'), + oldTemplate: currentTemplate, + newTemplate: stack, + }); + const securityDiff = formatter.formatSecurityDiff({ requireApproval, - ); + }); if (securityDiff.formattedDiff) { info(securityDiff.formattedDiff); await askUserConfirmation( diff --git a/packages/aws-cdk/lib/commands/diff.ts b/packages/aws-cdk/lib/commands/diff.ts index a87a6d03b..154cad37a 100644 --- a/packages/aws-cdk/lib/commands/diff.ts +++ b/packages/aws-cdk/lib/commands/diff.ts @@ -1 +1 @@ -export { formatStackDiff, formatSecurityDiff, RequireApproval } from '../../../@aws-cdk/tmp-toolkit-helpers/src/api'; +export { DiffFormatter, RequireApproval } from '../../../@aws-cdk/tmp-toolkit-helpers/src/api';