From d0c221dca77bed5338b0253f118cd877cdc5e14b Mon Sep 17 00:00:00 2001 From: 9pace Date: Tue, 25 Aug 2026 14:28:05 -0400 Subject: [PATCH] feat(cli): write cdk diff as machine-readable JSON to a file with --json-file Adds a '--json-file ' option to 'cdk diff' that writes the computed diff as a JSON document to the given path, while leaving the console output unchanged. The file maps each selected stack to its structural template diff (per-property old/new values with change impact), plus the IAM policy changes and security group rule changes already computed by @aws-cdk/cloudformation-diff, serialized by a new templateDiffToJson() function in that package. --- ...cument-and-keeps-human-output.integtest.ts | 39 +++ .../lib/diff/render-json.ts | 239 +++++++++++++++ .../@aws-cdk/cloudformation-diff/lib/index.ts | 1 + .../test/render-json.test.ts | 288 ++++++++++++++++++ packages/aws-cdk/README.md | 51 ++++ packages/aws-cdk/lib/cli/cdk-toolkit.ts | 34 +++ packages/aws-cdk/lib/cli/cli-config.ts | 1 + .../aws-cdk/lib/cli/cli-type-registry.json | 5 + packages/aws-cdk/lib/cli/cli.ts | 1 + .../aws-cdk/lib/cli/convert-to-user-input.ts | 2 + .../lib/cli/parse-command-line-arguments.ts | 6 + packages/aws-cdk/lib/cli/user-input.ts | 7 + .../aws-cdk/test/cli/diff-options.test.ts | 20 ++ packages/aws-cdk/test/commands/diff.test.ts | 111 +++++++ 14 files changed, 805 insertions(+) create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/diff/cdk-cdk-diff---json-file-writes-diff-document-and-keeps-human-output.integtest.ts create mode 100644 packages/@aws-cdk/cloudformation-diff/lib/diff/render-json.ts create mode 100644 packages/@aws-cdk/cloudformation-diff/test/render-json.test.ts diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/diff/cdk-cdk-diff---json-file-writes-diff-document-and-keeps-human-output.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/diff/cdk-cdk-diff---json-file-writes-diff-document-and-keeps-human-output.integtest.ts new file mode 100644 index 000000000..5d4e35e6a --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/diff/cdk-cdk-diff---json-file-writes-diff-document-and-keeps-human-output.integtest.ts @@ -0,0 +1,39 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest( + 'cdk diff --json-file writes diff document and keeps human output', + withDefaultFixture(async (fixture) => { + // GIVEN: an undeployed stack containing an IAM role + const stackName = fixture.fullStackName('iam-test'); + const jsonFile = path.join(fixture.integTestDir, 'diff.json'); + + // WHEN + const diff = await fixture.cdk(['diff', `--json-file=${jsonFile}`, stackName]); + + // THEN: the human-readable diff is still printed + expect(diff).toContain(`Stack ${stackName}`); + expect(diff).toContain('IAM Statement Changes'); + + // AND the file contains the machine-readable diff + const doc = JSON.parse(await fs.readFile(jsonFile, { encoding: 'utf-8' })); + const templateDiff = doc[stackName][stackName]; + expect(templateDiff.permissionsBroadened).toBe(true); + + // The new role must show up both as a resource change and as an IAM statement addition + const resourceChanges: any[] = Object.values(templateDiff.resources); + expect(resourceChanges).toContainEqual(expect.objectContaining({ + newResourceType: 'AWS::IAM::Role', + isAddition: true, + changeImpact: 'WILL_CREATE', + })); + expect(templateDiff.iamChanges.statementAdditions).toContainEqual({ + type: 'parsed', + value: expect.objectContaining({ + effect: 'Allow', + actions: { not: false, values: ['sts:AssumeRole'] }, + }), + }); + }), +); diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/render-json.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/render-json.ts new file mode 100644 index 000000000..8c5883c4f --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/render-json.ts @@ -0,0 +1,239 @@ +import type { IamChangesJson } from '../iam/iam-changes'; +import type { SecurityGroupChangesJson } from '../network/security-group-changes'; +import { deepRemoveUndefined } from '../util'; +import type { Difference, Move, PropertyDifference, ResourceImpact, TemplateDiff } from './types'; + +/** + * A machine-readable rendering of a single value difference + */ +export interface DifferenceJson { + /** + * The old value, if any + */ + readonly oldValue?: any; + + /** + * The new value, if any + */ + readonly newValue?: any; +} + +/** + * A machine-readable rendering of a single property difference of a resource + */ +export interface PropertyDifferenceJson extends DifferenceJson { + /** + * The impact this property change has on the resource + */ + readonly changeImpact?: ResourceImpact; +} + +/** + * A machine-readable rendering of a single resource difference + */ +export interface ResourceDifferenceJson { + /** + * The resource type before the change, if the resource existed before + */ + readonly oldResourceType?: string; + + /** + * The resource type after the change, if the resource still exists + */ + readonly newResourceType?: string; + + /** + * The impact this change has on the physical resource + */ + readonly changeImpact: ResourceImpact; + + /** + * Whether the resource is newly added to the template + */ + readonly isAddition: boolean; + + /** + * Whether the resource is removed from the template + */ + readonly isRemoval: boolean; + + /** + * Whether the resource is imported rather than created + */ + readonly isImport?: boolean; + + /** + * If this resource was moved from or to another stack, details about the move + */ + readonly move?: Move; + + /** + * Changes to the resource's properties, keyed by property name + */ + readonly propertyDiffs?: { [propertyName: string]: PropertyDifferenceJson }; + + /** + * Changes to non-property attributes of the resource (e.g. Metadata, DependsOn), keyed by attribute name + */ + readonly otherDiffs?: { [key: string]: DifferenceJson }; +} + +/** + * A machine-readable rendering of a template diff + */ +export interface TemplateDiffJson { + /** + * Change to the AWSTemplateFormatVersion field + */ + readonly awsTemplateFormatVersion?: DifferenceJson; + + /** + * Change to the template Description + */ + readonly description?: DifferenceJson; + + /** + * Change to the template Transform + */ + readonly transform?: DifferenceJson; + + /** + * Changes to resources, keyed by logical ID + */ + readonly resources?: { [logicalId: string]: ResourceDifferenceJson }; + + /** + * Changes to parameters, keyed by logical ID + */ + readonly parameters?: { [logicalId: string]: DifferenceJson }; + + /** + * Changes to outputs, keyed by logical ID + */ + readonly outputs?: { [logicalId: string]: DifferenceJson }; + + /** + * Changes to conditions, keyed by logical ID + */ + readonly conditions?: { [logicalId: string]: DifferenceJson }; + + /** + * Changes to mappings, keyed by logical ID + */ + readonly mappings?: { [logicalId: string]: DifferenceJson }; + + /** + * Changes to template metadata, keyed by logical ID + */ + readonly metadata?: { [logicalId: string]: DifferenceJson }; + + /** + * Changes to unclassified template elements + */ + readonly unknown?: { [logicalId: string]: DifferenceJson }; + + /** + * IAM policy changes contained in this diff + */ + readonly iamChanges?: IamChangesJson; + + /** + * Security group rule changes contained in this diff + */ + readonly securityGroupChanges?: SecurityGroupChangesJson; + + /** + * Whether IAM permissions or security group rules are broadened by this diff + */ + readonly permissionsBroadened: boolean; + + /** + * The number of differences in this diff + */ + readonly differenceCount: number; +} + +/** + * Render a TemplateDiff to a plain JSON-serializable object. + * + * Contains the full structural diff of the template, plus dedicated + * sections for IAM policy changes and security group rule changes. + */ +export function templateDiffToJson(templateDiff: TemplateDiff): TemplateDiffJson { + const resources: { [logicalId: string]: ResourceDifferenceJson } = {}; + templateDiff.resources.forEachDifference((logicalId, change) => { + const propertyDiffs: { [propertyName: string]: PropertyDifferenceJson } = {}; + const otherDiffs: { [key: string]: DifferenceJson } = {}; + change.forEachDifference((type, name, diff) => { + if (type === 'Property') { + propertyDiffs[name] = propertyDifferenceToJson(diff as PropertyDifference); + } else { + otherDiffs[name] = differenceToJson(diff); + } + }); + + resources[logicalId] = { + oldResourceType: change.oldResourceType, + newResourceType: change.newResourceType, + changeImpact: change.changeImpact, + isAddition: change.isAddition, + isRemoval: change.isRemoval, + isImport: change.isImport, + move: change.move, + propertyDiffs: dropIfEmptyObject(propertyDiffs), + otherDiffs: dropIfEmptyObject(otherDiffs), + }; + }); + + return deepRemoveUndefined({ + awsTemplateFormatVersion: optionalDifferenceToJson(templateDiff.awsTemplateFormatVersion), + description: optionalDifferenceToJson(templateDiff.description), + transform: optionalDifferenceToJson(templateDiff.transform), + resources: dropIfEmptyObject(resources), + parameters: differenceCollectionToJson(templateDiff.parameters), + outputs: differenceCollectionToJson(templateDiff.outputs), + conditions: differenceCollectionToJson(templateDiff.conditions), + mappings: differenceCollectionToJson(templateDiff.mappings), + metadata: differenceCollectionToJson(templateDiff.metadata), + unknown: differenceCollectionToJson(templateDiff.unknown), + iamChanges: templateDiff.iamChanges.hasChanges ? templateDiff.iamChanges._toJson() : undefined, + securityGroupChanges: templateDiff.securityGroupChanges.hasChanges ? templateDiff.securityGroupChanges.toJson() : undefined, + permissionsBroadened: templateDiff.permissionsBroadened, + differenceCount: templateDiff.differenceCount, + }); +} + +function differenceCollectionToJson( + collection: { forEachDifference: (cb: (logicalId: string, change: Difference) => any) => void } | undefined, +): { [logicalId: string]: DifferenceJson } | undefined { + if (!collection) { + return undefined; + } + const ret: { [logicalId: string]: DifferenceJson } = {}; + collection.forEachDifference((logicalId, change) => { + ret[logicalId] = differenceToJson(change); + }); + return dropIfEmptyObject(ret); +} + +function optionalDifferenceToJson(difference?: Difference): DifferenceJson | undefined { + return difference?.isDifferent ? differenceToJson(difference) : undefined; +} + +function differenceToJson(difference: Difference): DifferenceJson { + return { + oldValue: difference.oldValue, + newValue: difference.newValue, + }; +} + +function propertyDifferenceToJson(difference: PropertyDifference): PropertyDifferenceJson { + return { + ...differenceToJson(difference), + changeImpact: difference.changeImpact, + }; +} + +function dropIfEmptyObject(x: T): T | undefined { + return Object.keys(x).length > 0 ? x : undefined; +} diff --git a/packages/@aws-cdk/cloudformation-diff/lib/index.ts b/packages/@aws-cdk/cloudformation-diff/lib/index.ts index 8aa362d7c..5ca85425b 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/index.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/index.ts @@ -4,3 +4,4 @@ export * from './format-foreach'; export * from './format-table'; export * from './mappings'; export { deepEqual, mangleLikeCloudFormation } from './diff/util'; +export * from './diff/render-json'; diff --git a/packages/@aws-cdk/cloudformation-diff/test/render-json.test.ts b/packages/@aws-cdk/cloudformation-diff/test/render-json.test.ts new file mode 100644 index 000000000..09aa89fd1 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/render-json.test.ts @@ -0,0 +1,288 @@ +import { fullDiff, ResourceImpact, templateDiffToJson } from '../lib'; +import { poldoc, policy, resource, role, template } from './util'; + +describe('resources', () => { + test('renders an added resource', () => { + const diff = fullDiff({}, template({ + MyBucket: resource('AWS::S3::Bucket', { BucketName: 'my-bucket' }), + })); + + const json = templateDiffToJson(diff); + + expect(json.resources).toEqual({ + MyBucket: expect.objectContaining({ + newResourceType: 'AWS::S3::Bucket', + changeImpact: ResourceImpact.WILL_CREATE, + isAddition: true, + isRemoval: false, + }), + }); + expect(json.differenceCount).toBe(1); + expect(json.permissionsBroadened).toBe(false); + }); + + test('renders a removed resource', () => { + const diff = fullDiff(template({ + MyBucket: resource('AWS::S3::Bucket', { BucketName: 'my-bucket' }), + }), {}); + + const json = templateDiffToJson(diff); + + expect(json.resources).toEqual({ + MyBucket: expect.objectContaining({ + oldResourceType: 'AWS::S3::Bucket', + changeImpact: ResourceImpact.WILL_DESTROY, + isAddition: false, + isRemoval: true, + }), + }); + }); + + test('renders property-level changes with change impact', () => { + const diff = fullDiff(template({ + MyBucket: resource('AWS::S3::Bucket', { BucketName: 'old-name' }), + }), template({ + MyBucket: resource('AWS::S3::Bucket', { BucketName: 'new-name' }), + })); + + const json = templateDiffToJson(diff); + + expect(json.resources?.MyBucket.propertyDiffs).toEqual({ + BucketName: { + oldValue: 'old-name', + newValue: 'new-name', + changeImpact: ResourceImpact.WILL_REPLACE, + }, + }); + }); + + test('renders non-property attribute changes', () => { + const diff = fullDiff(template({ + MyBucket: { Type: 'AWS::S3::Bucket', Properties: {}, DeletionPolicy: 'Delete' }, + }), template({ + MyBucket: { Type: 'AWS::S3::Bucket', Properties: {}, DeletionPolicy: 'Retain' }, + })); + + const json = templateDiffToJson(diff); + + expect(json.resources?.MyBucket.otherDiffs).toEqual({ + DeletionPolicy: { + oldValue: 'Delete', + newValue: 'Retain', + }, + }); + }); + + test('the JSON document round-trips through JSON.stringify', () => { + const diff = fullDiff(template({ + MyBucket: resource('AWS::S3::Bucket', { BucketName: 'old-name' }), + }), template({ + MyBucket: resource('AWS::S3::Bucket', { BucketName: 'new-name' }), + MyRole: role({ + AssumeRolePolicyDocument: poldoc({ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'lambda.amazonaws.com' }, + }), + }), + })); + + const json = templateDiffToJson(diff); + + expect(JSON.parse(JSON.stringify(json))).toEqual(json); + }); +}); + +describe('iamChanges', () => { + test('renders IAM statement additions with resources and attributes', () => { + const diff = fullDiff({}, template({ + MyPolicy: policy({ + Roles: [{ Ref: 'MyRole' }], + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::my-bucket/*', + }), + }), + })); + + const json = templateDiffToJson(diff); + + expect(json.permissionsBroadened).toBe(true); + expect(json.iamChanges).toEqual({ + statementAdditions: [ + { + type: 'parsed', + value: { + effect: 'Allow', + resources: { not: false, values: ['arn:aws:s3:::my-bucket/*'] }, + principals: { not: false, values: ['AWS:${MyRole}'] }, + actions: { not: false, values: ['s3:GetObject'] }, + }, + }, + ], + }); + }); + + test('renders IAM statement removals', () => { + const diff = fullDiff(template({ + MyPolicy: policy({ + Roles: [{ Ref: 'MyRole' }], + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: '*', + }), + }), + }), {}); + + const json = templateDiffToJson(diff); + + expect(json.iamChanges?.statementRemovals).toHaveLength(1); + expect(json.permissionsBroadened).toBe(false); + }); + + test('renders managed policy changes', () => { + const diff = fullDiff(template({ + MyRole: role({ + AssumeRolePolicyDocument: poldoc({ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'lambda.amazonaws.com' }, + }), + }), + }), template({ + MyRole: role({ + AssumeRolePolicyDocument: poldoc({ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'lambda.amazonaws.com' }, + }), + ManagedPolicyArns: ['arn:aws:iam::aws:policy/AdministratorAccess'], + }), + })); + + const json = templateDiffToJson(diff); + + expect(json.iamChanges?.managedPolicyAdditions).toEqual([ + { + type: 'parsed', + value: { + identityArn: '${MyRole}', + managedPolicyArn: 'arn:aws:iam::aws:policy/AdministratorAccess', + }, + }, + ]); + }); + + test('renders unparseable statements as unparseable', () => { + const diff = fullDiff({}, template({ + MyPolicy: policy({ + Roles: [{ Ref: 'MyRole' }], + PolicyDocument: poldoc({ + 'Fn::If': ['SomeCondition', { Effect: 'Allow', Action: '*', Resource: '*' }, { Ref: 'AWS::NoValue' }], + }), + }), + })); + + const json = templateDiffToJson(diff); + + expect(json.iamChanges?.statementAdditions).toEqual([ + expect.objectContaining({ type: 'unparseable' }), + ]); + }); + + test('omits iamChanges when there are none', () => { + const diff = fullDiff(template({ + MyBucket: resource('AWS::S3::Bucket', {}), + }), template({ + MyBucket: resource('AWS::S3::Bucket', { BucketName: 'name' }), + })); + + const json = templateDiffToJson(diff); + + expect(json.iamChanges).toBeUndefined(); + }); +}); + +describe('securityGroupChanges', () => { + test('renders added ingress rules', () => { + const diff = fullDiff({}, template({ + MySecurityGroup: resource('AWS::EC2::SecurityGroup', { + GroupDescription: 'My security group', + SecurityGroupIngress: [ + { + IpProtocol: 'tcp', + FromPort: 443, + ToPort: 443, + CidrIp: '0.0.0.0/0', + }, + ], + }), + })); + + const json = templateDiffToJson(diff); + + expect(json.permissionsBroadened).toBe(true); + expect(json.securityGroupChanges?.ingressRuleAdditions).toEqual([ + expect.objectContaining({ + ipProtocol: 'tcp', + fromPort: 443, + toPort: 443, + peer: { kind: 'cidr-ip', ip: '0.0.0.0/0' }, + }), + ]); + }); +}); + +describe('template sections', () => { + test('renders parameter, output and condition changes', () => { + const diff = fullDiff({ + Parameters: { Stage: { Type: 'String', Default: 'dev' } }, + Outputs: { MyOutput: { Value: 'old' } }, + Conditions: { IsProd: { 'Fn::Equals': [{ Ref: 'Stage' }, 'prod'] } }, + }, { + Parameters: { Stage: { Type: 'String', Default: 'prod' } }, + Outputs: { MyOutput: { Value: 'new' } }, + Conditions: { IsProd: { 'Fn::Equals': [{ Ref: 'Stage' }, 'production'] } }, + }); + + const json = templateDiffToJson(diff); + + expect(json.parameters?.Stage).toEqual({ + oldValue: { Type: 'String', Default: 'dev' }, + newValue: { Type: 'String', Default: 'prod' }, + }); + expect(json.outputs?.MyOutput).toEqual({ + oldValue: { Value: 'old' }, + newValue: { Value: 'new' }, + }); + expect(json.conditions?.IsProd).toBeDefined(); + }); + + test('renders description changes', () => { + const diff = fullDiff({ Description: 'old description' }, { Description: 'new description' }); + + const json = templateDiffToJson(diff); + + expect(json.description).toEqual({ + oldValue: 'old description', + newValue: 'new description', + }); + }); + + test('an empty diff renders an empty document', () => { + const diff = fullDiff(template({ + MyBucket: resource('AWS::S3::Bucket', {}), + }), template({ + MyBucket: resource('AWS::S3::Bucket', {}), + })); + + const json = templateDiffToJson(diff); + + expect(json).toEqual({ + permissionsBroadened: false, + differenceCount: 0, + }); + }); +}); diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index e44a565a9..51f0f45c3 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -244,6 +244,57 @@ In the output above: [+] indicates a new resource that would be created. [←] indicates a resource that would be imported into the stack instead. +#### Machine-readable output + +For programmatic consumption of the diff — e.g. attaching evidence of infrastructure changes to a +code review — `--json-file` writes the diff as JSON to a file, while still printing the +human-readable diff to the console. It can be combined with any other diff option. + +```console +$ cdk diff --json-file=diff.json MyStackName +``` + +The file contains one entry per selected stack, each mapping stack names (including nested stacks) +to their structural diff: resource changes with per-property old/new values and change impact +(e.g. `WILL_REPLACE`), plus dedicated sections for IAM policy changes (`iamChanges`) and security +group rule changes (`securityGroupChanges`), and a `permissionsBroadened` flag. + +```json +{ + "MyStackName": { + "MyStackName": { + "resources": { + "MyRoleDefaultPolicy": { + "newResourceType": "AWS::IAM::Policy", + "changeImpact": "WILL_CREATE", + "isAddition": true, + "isRemoval": false + } + }, + "iamChanges": { + "statementAdditions": [ + { + "type": "parsed", + "value": { + "effect": "Allow", + "resources": { "not": false, "values": ["${MyBucket.Arn}"] }, + "actions": { "not": false, "values": ["s3:GetObject"] }, + "principals": { "not": false, "values": ["AWS:${MyRole}"] } + } + } + ] + }, + "permissionsBroadened": true, + "differenceCount": 1 + } + } +} +``` + +IAM statements that cannot be fully resolved (e.g. because they contain unresolved CloudFormation +intrinsics) are rendered as `{ "type": "unparseable", "repr": "..." }` instead of +`{ "type": "parsed", "value": ... }` — consumers should handle both variants. + ### `cdk deploy` Deploys a stack of your CDK app to its environment. During the deployment, the toolkit will output progress diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 5f6a38a7e..216dec86a 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -4,6 +4,8 @@ import { format } from 'node:util'; import type { IManifestEntry } from '@aws-cdk/cdk-assets-lib'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; import { RequireApproval } from '@aws-cdk/cloud-assembly-schema'; +import type { TemplateDiffJson } from '@aws-cdk/cloudformation-diff'; +import { templateDiffToJson } from '@aws-cdk/cloudformation-diff'; import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib'; import { PermissionChangeType, Toolkit, ToolkitError, AbortError } from '@aws-cdk/toolkit-lib'; import chalk from 'chalk'; @@ -85,6 +87,10 @@ const pLimit: typeof import('p-limit') = require('p-limit'); const FILE_EVENTS = [EVENTS.ADD, EVENTS.ADD_DIR, EVENTS.CHANGE, EVENTS.UNLINK, EVENTS.UNLINK_DIR] as const; type FileEvent = typeof FILE_EVENTS[number]; +function mapObject(obj: { [key: string]: T }, fn: (value: T) => U): { [key: string]: U } { + return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, fn(value)])); +} + /** * Type guard to check if an event is a file event we should process. */ @@ -287,6 +293,7 @@ export class CdkToolkit { const quiet = options.quiet || false; let diffs = 0; + const jsonDiffs: { [rootStackName: string]: { [stackName: string]: TemplateDiffJson } } = {}; const parameterMap = buildParameterMap(options.parameters); if (options.templatePath !== undefined) { @@ -334,6 +341,10 @@ export class CdkToolkit { diffs = diff.numStacksWithChanges; await this.ioHost.asIoHelper().defaults.info(diff.formattedDiff); } + + if (options.jsonFile !== undefined) { + jsonDiffs[stacks.firstStack.displayName ?? stacks.firstStack.stackName] = mapObject(formatter.diffs, templateDiffToJson); + } } else { const allMappings = options.includeMoves ? await mappingsByEnvironment(stacks.stackArtifacts, this.props.sdkProvider, true) @@ -396,6 +407,21 @@ export class CdkToolkit { await this.ioHost.asIoHelper().defaults.info(diff.formattedDiff); diffs += diff.numStacksWithChanges; } + + if (options.jsonFile !== undefined) { + jsonDiffs[stack.displayName ?? stack.stackName] = mapObject(formatter.diffs, templateDiffToJson); + } + } + } + + if (options.jsonFile !== undefined) { + fs.ensureFileSync(options.jsonFile); + await fs.writeJson(options.jsonFile, jsonDiffs, { + spaces: 2, + encoding: 'utf8', + }); + if (!quiet) { + await this.ioHost.asIoHelper().defaults.info(format('JSON diff written to %s\n', options.jsonFile)); } } @@ -1606,6 +1632,14 @@ export interface DiffOptions { * @default false */ readonly includeMoves?: boolean; + + /** + * Path to a file where the diff will be written as machine-readable JSON. + * The human-readable diff is still printed to the console. + * + * @default - diff is not written to a file + */ + readonly jsonFile?: string; } interface CfnDeployOptions { diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index a071e003f..cebf4f58c 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -427,6 +427,7 @@ export async function makeConfig(): Promise { }, 'import-existing-resources': { type: 'boolean', desc: 'Whether or not the change set imports resources that already exist', default: false }, 'include-moves': { type: 'boolean', desc: 'Whether to include moves in the diff', default: false }, + 'json-file': { type: 'string', desc: 'Path to file where the diff will be written as machine-readable JSON. The human-readable diff is still printed', requiresArg: true }, }, }, 'drift': { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 9d7194d9f..3e647b49d 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -959,6 +959,11 @@ "type": "boolean", "desc": "Whether to include moves in the diff", "default": false + }, + "json-file": { + "type": "string", + "desc": "Path to file where the diff will be written as machine-readable JSON. The human-readable diff is still printed", + "requiresArg": true } } }, diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index b8b06353a..91413206d 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -364,6 +364,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { default: false, type: 'boolean', desc: 'Whether to include moves in the diff', + }) + .option('json-file', { + default: undefined, + type: 'string', + desc: 'Path to file where the diff will be written as machine-readable JSON. The human-readable diff is still printed', + requiresArg: true, }), ) .command('drift [STACKS..]', 'Detect drifts in the given CloudFormation stack(s)', (yargs: Argv) => diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 688635f5b..7dd589308 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -1509,6 +1509,13 @@ export interface DiffOptions { */ readonly includeMoves?: boolean; + /** + * Path to file where the diff will be written as machine-readable JSON. The human-readable diff is still printed + * + * @default - undefined + */ + readonly jsonFile?: string; + /** * Positional argument for diff */ diff --git a/packages/aws-cdk/test/cli/diff-options.test.ts b/packages/aws-cdk/test/cli/diff-options.test.ts index 554c06cc5..f9edf24ee 100644 --- a/packages/aws-cdk/test/cli/diff-options.test.ts +++ b/packages/aws-cdk/test/cli/diff-options.test.ts @@ -47,3 +47,23 @@ describe('diff --method option', () => { expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'template' })); }); }); + +describe('diff --json-file option', () => { + test('jsonFile defaults to unset', async () => { + await exec(['diff', '--app', 'echo']); + expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ jsonFile: undefined })); + }); + + test('--json-file is forwarded', async () => { + await exec(['diff', '--app', 'echo', '--json-file=diff.json']); + expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ jsonFile: 'diff.json' })); + }); + + test('--json-file does not swallow stack names', async () => { + await exec(['diff', '--app', 'echo', '--json-file=diff.json', 'MyStack']); + expect(diffSpy).toHaveBeenCalledWith(expect.objectContaining({ + stackNames: ['MyStack'], + jsonFile: 'diff.json', + })); + }); +}); diff --git a/packages/aws-cdk/test/commands/diff.test.ts b/packages/aws-cdk/test/commands/diff.test.ts index c8b22b35d..c1d2b61e4 100644 --- a/packages/aws-cdk/test/commands/diff.test.ts +++ b/packages/aws-cdk/test/commands/diff.test.ts @@ -673,6 +673,117 @@ describe('non-nested stacks', () => { })); expect(exitCode).toBe(0); }); + + describe('machine-readable JSON output', () => { + test('jsonFile writes the diff to disk and keeps the human diff unchanged', async () => { + const jsonFile = path.join(tmpDir, 'diff-output.json'); + + // WHEN + const exitCode = await toolkit.diff({ + stackNames: ['A'], + jsonFile, + }); + + // THEN + const doc = JSON.parse(fs.readFileSync(jsonFile, { encoding: 'utf-8' })); + expect(doc.A.A.differenceCount).toBe(1); + + const plainTextOutput = output(); + expect(plainTextOutput).toContain('Stack A'); + expect(plainTextOutput).toContain('✨ Number of stacks with differences: 1'); + expect(exitCode).toBe(0); + }); + + test('jsonFile includes IAM policy changes with resources and attributes', async () => { + // GIVEN + cloudExecutable = await MockCloudExecutable.create({ + stacks: [ + { + stackName: 'A', + template: { + Resources: { + MyPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + Roles: [{ Ref: 'MyRole' }], + PolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::my-bucket/*', + }, + ], + }, + }, + }, + }, + }, + }, + ], + }, undefined, ioHost); + toolkit = new CdkToolkit({ + cloudExecutable, + deployments: cloudFormation, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + }); + cloudFormation.readCurrentTemplateWithNestedStacks.mockResolvedValue({ + deployedRootTemplate: {}, + nestedStacks: {}, + }); + const jsonFile = path.join(tmpDir, 'diff-iam.json'); + + // WHEN + await toolkit.diff({ + stackNames: ['A'], + jsonFile, + }); + + // THEN + const doc = JSON.parse(fs.readFileSync(jsonFile, { encoding: 'utf-8' })); + const templateDiff = doc.A.A; + expect(templateDiff.permissionsBroadened).toBe(true); + expect(templateDiff.iamChanges.statementAdditions).toEqual([ + { + type: 'parsed', + value: expect.objectContaining({ + effect: 'Allow', + resources: { not: false, values: ['arn:aws:s3:::my-bucket/*'] }, + actions: { not: false, values: ['s3:GetObject'] }, + }), + }, + ]); + }); + + test('jsonFile covers multiple stacks', async () => { + const jsonFile = path.join(tmpDir, 'diff-multi.json'); + + // WHEN + await toolkit.diff({ + stackNames: ['A', 'D'], + jsonFile, + }); + + // THEN + const doc = JSON.parse(fs.readFileSync(jsonFile, { encoding: 'utf-8' })); + expect(Object.keys(doc)).toEqual(['A', 'D']); + expect(doc.D.D.differenceCount).toBe(0); + }); + + test('exit code is still 1 with fail and jsonFile set', async () => { + // WHEN + const exitCode = await toolkit.diff({ + stackNames: ['A'], + fail: true, + jsonFile: path.join(tmpDir, 'diff-fail.json'), + }); + + // THEN + expect(exitCode).toBe(1); + }); + }); }); describe('stack exists checks', () => {