diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/index.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/index.ts index 6a1e9d48e..28a0d6237 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/index.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/index.ts @@ -26,7 +26,18 @@ export function diffParameter(oldValue: types.Parameter, newValue: types.Paramet return new types.ParameterDifference(oldValue, newValue); } -export function diffResource(oldValue?: types.Resource, newValue?: types.Resource): types.ResourceDifference { +export function diffResource(oldValue?: types.Resource, newValue?: types.Resource, logicalId?: string): types.ResourceDifference { + // Fn::ForEach entries are arrays, not standard {Type, Properties} resource objects. + // Synthesize a virtual resource type so isDifferent works correctly. + if (logicalId && logicalId.startsWith('Fn::ForEach::')) { + const forEachType = 'Fn::ForEach'; + return new types.ResourceDifference(oldValue, newValue, { + resourceType: { oldType: oldValue ? forEachType : undefined, newType: newValue ? forEachType : undefined }, + propertyDiffs: {}, + otherDiffs: !deepEqual(oldValue, newValue) ? { Value: new types.Difference(oldValue, newValue) } : {}, + }); + } + const resourceType = { oldType: oldValue && oldValue.Type, newType: newValue && newValue.Type, diff --git a/packages/@aws-cdk/cloudformation-diff/lib/format-foreach.ts b/packages/@aws-cdk/cloudformation-diff/lib/format-foreach.ts new file mode 100644 index 000000000..687f0b36e --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/format-foreach.ts @@ -0,0 +1,122 @@ +import * as chalk from 'chalk'; +import { deepEqual } from './diff/util'; + +const ADDITION = chalk.green('[+]'); +const UPDATE = chalk.yellow('[~]'); +const REMOVAL = chalk.red('[-]'); + +/** + * Formatter for Fn::ForEach diff output. + * + * Fn::ForEach is a CloudFormation intrinsic that expands a template fragment + * over a collection at deploy time. Its value is an array: + * [collection, { "LogicalId${Var}": { Type, Properties } }] + */ +export class ForEachDiffFormatter { + /** + * Format a ForEach resource difference + */ + public formatForEach( + key: string, + oldValue: any | undefined, + newValue: any | undefined, + ): string[] { + const lines: string[] = []; + const changeType = this.getChangeType(oldValue, newValue); + const value = newValue ?? oldValue; + + if (!Array.isArray(value) || value.length < 2) { + lines.push(`${this.changeSymbol(changeType)} ${chalk.cyan(key)} (unrecognized ForEach structure)`); + return lines; + } + + const loopName = key.replace('Fn::ForEach::', ''); + const [collection, templateObj] = value; + const entries = Object.entries(templateObj as Record); + + if (entries.length === 0) { + lines.push(`${this.changeSymbol(changeType)} ${chalk.cyan(key)} (empty ForEach template)`); + return lines; + } + + const [[templateKey, templateValue]] = entries; + + const count = Array.isArray(collection) + ? `${collection.length} resources` + : 'dynamic count'; + + lines.push( + `${this.changeSymbol(changeType)} ${chalk.cyan(key)} (expands to ${count} at deploy time)`, + ); + lines.push(` Loop variable: ${chalk.blue(loopName)}`); + lines.push(` Collection: ${this.formatCollection(collection)}`); + lines.push(` └── ${templateKey} ${chalk.cyan(templateValue?.Type ?? 'Unknown')}`); + + if (changeType === 'update' && oldValue && newValue) { + const oldProps = oldValue[1]?.[Object.keys(oldValue[1])[0]]?.Properties ?? {}; + const newProps = newValue[1]?.[Object.keys(newValue[1])[0]]?.Properties ?? {}; + const propDiff = this.diffProperties(oldProps, newProps); + lines.push(...propDiff.map(l => ` ${l}`)); + } else { + for (const [propKey, propValue] of Object.entries(templateValue?.Properties ?? {})) { + lines.push(` ${propKey}: ${this.formatValue(propValue)}`); + } + } + + return lines; + } + + private changeSymbol(type: 'add' | 'remove' | 'update'): string { + switch (type) { + case 'add': return ADDITION; + case 'remove': return REMOVAL; + case 'update': return UPDATE; + } + } + + private formatCollection(collection: any): string { + if (Array.isArray(collection)) { + if (collection.length <= 5) return JSON.stringify(collection); + return `[${collection.slice(0, 3).join(', ')}, ... +${collection.length - 3} more]`; + } + return JSON.stringify(collection); + } + + private getChangeType(oldVal: any, newVal: any): 'add' | 'remove' | 'update' { + if (!oldVal) return 'add'; + if (!newVal) return 'remove'; + return 'update'; + } + + private formatValue(value: any): string { + if (typeof value === 'string') return value; + return JSON.stringify(value); + } + + private diffProperties(oldProps: Record, newProps: Record): string[] { + const lines: string[] = []; + const allKeys = new Set([...Object.keys(oldProps), ...Object.keys(newProps)]); + + for (const key of allKeys) { + const oldVal = oldProps[key]; + const newVal = newProps[key]; + + if (oldVal === undefined) { + lines.push(`${ADDITION} ${key}: ${this.formatValue(newVal)}`); + } else if (newVal === undefined) { + lines.push(`${REMOVAL} ${key}: ${this.formatValue(oldVal)}`); + } else if (!deepEqual(oldVal, newVal)) { + lines.push(`${UPDATE} ${key}: ${this.formatValue(oldVal)} → ${this.formatValue(newVal)}`); + } + } + + return lines; + } +} + +/** + * Check if a logical ID represents a ForEach construct + */ +export function isForEachKey(logicalId: string): boolean { + return logicalId.startsWith('Fn::ForEach::'); +} diff --git a/packages/@aws-cdk/cloudformation-diff/lib/format.ts b/packages/@aws-cdk/cloudformation-diff/lib/format.ts index 67bbb0f60..9a2cd88ab 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/format.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/format.ts @@ -4,6 +4,7 @@ import type { DifferenceCollection, Move, TemplateDiff } from './diff/types'; import { deepEqual } from './diff/util'; import type { Difference, ResourceDifference } from './diff-template'; import { isPropertyDifference, ResourceImpact } from './diff-template'; +import { ForEachDiffFormatter, isForEachKey } from './format-foreach'; import { formatTable } from './format-table'; import type { IamChanges } from './iam/iam-changes'; import type { SecurityGroupChanges } from './network/security-group-changes'; @@ -164,6 +165,16 @@ export class Formatter { return; } + // Handle Fn::ForEach resources specially + if (isForEachKey(logicalId)) { + const forEachFormatter = new ForEachDiffFormatter(); + const lines = forEachFormatter.formatForEach(logicalId, diff.oldValue, diff.newValue); + for (const line of lines) { + this.print(line); + } + return; + } + const resourceType = diff.isRemoval ? diff.oldResourceType : diff.newResourceType; const message = [ diff --git a/packages/@aws-cdk/cloudformation-diff/lib/index.ts b/packages/@aws-cdk/cloudformation-diff/lib/index.ts index 77cf3dc6f..8aa362d7c 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/index.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/index.ts @@ -1,5 +1,6 @@ export * from './diff-template'; export * from './format'; +export * from './format-foreach'; export * from './format-table'; export * from './mappings'; export { deepEqual, mangleLikeCloudFormation } from './diff/util'; diff --git a/packages/@aws-cdk/cloudformation-diff/test/format-foreach.test.ts b/packages/@aws-cdk/cloudformation-diff/test/format-foreach.test.ts new file mode 100644 index 000000000..2b7dd0898 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/format-foreach.test.ts @@ -0,0 +1,261 @@ +import { ForEachDiffFormatter, isForEachKey, fullDiff, formatDifferences } from '../lib'; + +describe('isForEachKey', () => { + test('returns true for ForEach keys', () => { + expect(isForEachKey('Fn::ForEach::Env')).toBe(true); + expect(isForEachKey('Fn::ForEach::Item')).toBe(true); + expect(isForEachKey('Fn::ForEach::MyLoop')).toBe(true); + }); + + test('returns false for non-ForEach keys', () => { + expect(isForEachKey('MyBucket')).toBe(false); + expect(isForEachKey('AWS::S3::Bucket')).toBe(false); + expect(isForEachKey('Fn::GetAtt')).toBe(false); + }); +}); + +describe('ForEachDiffFormatter', () => { + const formatter = new ForEachDiffFormatter(); + + const forEachValue = [ + ['dev', 'prod'], + { + 'Bucket${Env}': { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: 'test-${Env}', + }, + }, + }, + ]; + + test('formats ForEach addition', () => { + const lines = formatter.formatForEach('Fn::ForEach::Env', undefined, forEachValue); + + expect(lines.length).toBeGreaterThan(0); + expect(lines[0]).toContain('[+]'); + expect(lines[0]).toContain('Fn::ForEach::Env'); + expect(lines[0]).toContain('2 resources'); + expect(lines.some(l => l.includes('Loop variable'))).toBe(true); + expect(lines.some(l => l.includes('Env'))).toBe(true); + expect(lines.some(l => l.includes('Collection'))).toBe(true); + expect(lines.some(l => l.includes('AWS::S3::Bucket'))).toBe(true); + }); + + test('formats ForEach removal', () => { + const lines = formatter.formatForEach('Fn::ForEach::Env', forEachValue, undefined); + + expect(lines.length).toBeGreaterThan(0); + expect(lines[0]).toContain('[-]'); + expect(lines[0]).toContain('Fn::ForEach::Env'); + }); + + test('formats ForEach update', () => { + const oldValue = [ + ['dev', 'prod'], + { + 'Bucket${Env}': { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: 'old-${Env}', + }, + }, + }, + ]; + + const newValue = [ + ['dev', 'prod', 'staging'], + { + 'Bucket${Env}': { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: 'new-${Env}', + }, + }, + }, + ]; + + const lines = formatter.formatForEach('Fn::ForEach::Env', oldValue, newValue); + + expect(lines.length).toBeGreaterThan(0); + expect(lines[0]).toContain('[~]'); + expect(lines[0]).toContain('3 resources'); + }); + + test('handles dynamic collection', () => { + const dynamicValue = [ + { Ref: 'EnvList' }, + { + 'Bucket${Env}': { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + }, + ]; + + const lines = formatter.formatForEach('Fn::ForEach::Env', undefined, dynamicValue); + + expect(lines[0]).toContain('dynamic count'); + }); + + test('truncates large collections', () => { + const largeValue = [ + ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + { + 'Bucket${Item}': { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + }, + ]; + + const lines = formatter.formatForEach('Fn::ForEach::Item', undefined, largeValue); + + expect(lines.some(l => l.includes('+4 more'))).toBe(true); + }); + + test('handles malformed ForEach value gracefully', () => { + const lines = formatter.formatForEach('Fn::ForEach::Bad', undefined, ['only-one-element']); + expect(lines.length).toBe(1); + expect(lines[0]).toContain('unrecognized ForEach structure'); + }); + + test('handles empty template object gracefully', () => { + const lines = formatter.formatForEach('Fn::ForEach::Empty', undefined, [['a'], {}]); + expect(lines.length).toBe(1); + expect(lines[0]).toContain('empty ForEach template'); + }); +}); + +describe('ForEach integration (fullDiff → formatDifferences)', () => { + function collectOutput(cb: (stream: NodeJS.WritableStream) => void): string { + const chunks: string[] = []; + const stream = { + write(chunk: string) { + chunks.push(chunk); return true; + }, + end() { + }, + on() { + return this; + }, + once() { + return this; + }, + emit() { + return false; + }, + addListener() { + return this; + }, + removeListener() { + return this; + }, + } as any; + cb(stream); + return chunks.join(''); + } + + test('adding a ForEach resource produces diff output', () => { + const diff = fullDiff( + { Resources: {} }, + { + Resources: { + 'Fn::ForEach::Env': [ + ['dev', 'prod'], + { 'Bucket${Env}': { Type: 'AWS::S3::Bucket', Properties: { BucketName: 'app-${Env}' } } }, + ], + }, + }, + ); + + expect(diff.resources.differenceCount).toBe(1); + + const output = collectOutput((stream) => formatDifferences(stream, diff)); + expect(output).toContain('Fn::ForEach::Env'); + expect(output).toContain('2 resources'); + expect(output).toContain('AWS::S3::Bucket'); + }); + + test('removing a ForEach resource produces diff output', () => { + const diff = fullDiff( + { + Resources: { + 'Fn::ForEach::Env': [ + ['dev', 'prod'], + { 'Bucket${Env}': { Type: 'AWS::S3::Bucket', Properties: {} } }, + ], + }, + }, + { Resources: {} }, + ); + + expect(diff.resources.differenceCount).toBe(1); + + const output = collectOutput((stream) => formatDifferences(stream, diff)); + expect(output).toContain('Fn::ForEach::Env'); + expect(output).toContain('[-]'); + }); + + test('updating a ForEach resource produces diff output', () => { + const diff = fullDiff( + { + Resources: { + 'Fn::ForEach::Env': [ + ['dev', 'prod'], + { 'Bucket${Env}': { Type: 'AWS::S3::Bucket', Properties: { BucketName: 'old-${Env}' } } }, + ], + }, + }, + { + Resources: { + 'Fn::ForEach::Env': [ + ['dev', 'staging', 'prod'], + { 'Bucket${Env}': { Type: 'AWS::S3::Bucket', Properties: { BucketName: 'new-${Env}' } } }, + ], + }, + }, + ); + + expect(diff.resources.differenceCount).toBe(1); + + const output = collectOutput((stream) => formatDifferences(stream, diff)); + expect(output).toContain('[~]'); + expect(output).toContain('3 resources'); + }); + + test('unchanged ForEach resource produces no diff', () => { + const template = { + Resources: { + 'Fn::ForEach::Env': [ + ['dev', 'prod'], + { 'Bucket${Env}': { Type: 'AWS::S3::Bucket', Properties: {} } }, + ], + }, + }; + + const diff = fullDiff(template, template); + expect(diff.resources.differenceCount).toBe(0); + }); + + test('ForEach alongside regular resources diffs correctly', () => { + const diff = fullDiff( + { Resources: {} }, + { + Resources: { + 'MyBucket': { Type: 'AWS::S3::Bucket', Properties: { BucketName: 'solo' } }, + 'Fn::ForEach::Env': [ + ['dev', 'prod'], + { 'Bucket${Env}': { Type: 'AWS::S3::Bucket', Properties: {} } }, + ], + }, + }, + ); + + expect(diff.resources.differenceCount).toBe(2); + + const output = collectOutput((stream) => formatDifferences(stream, diff)); + expect(output).toContain('MyBucket'); + expect(output).toContain('Fn::ForEach::Env'); + }); +});