diff --git a/packages/@aws-cdk/cloud-assembly-api/README.md b/packages/@aws-cdk/cloud-assembly-api/README.md index e99ec17fa..059343848 100644 --- a/packages/@aws-cdk/cloud-assembly-api/README.md +++ b/packages/@aws-cdk/cloud-assembly-api/README.md @@ -17,3 +17,25 @@ responsibility has now been split: │ │◀────────────────────────│ │ └────────────────────────┘ @/cx-api └────────────────┘ ``` + +## Property mutation sites + +`AssemblyIndex` joins construct-tree data with assembly metadata. When an app is +synthesized in debug mode (`CDK_DEBUG=true`), recorded property assignment and +mutation traces are available on each indexed node: + +```ts +import { AssemblyIndex, CloudAssembly } from '@aws-cdk/cloud-assembly-api'; + +async function tagMutationStacks(assemblyDirectory: string): Promise { + const index = await AssemblyIndex.fromAssembly(new CloudAssembly(assemblyDirectory)); + const resource = index.byPath('MyStack/MyBucket/Resource'); + return resource?.propertyMutationStacks?.Tags ?? []; +} +``` + +The map uses CloudFormation property names and contains every recorded stack +trace for each property, in metadata order. The same data is available on nodes +returned by `byTemplateLocation()`, including resources in nested stacks and +nested assemblies. `propertyMutationStacks` is absent when no mutation metadata +was emitted. A construct's creation trace remains available as `creationStack`. diff --git a/packages/@aws-cdk/cloud-assembly-api/lib/assembly-index.ts b/packages/@aws-cdk/cloud-assembly-api/lib/assembly-index.ts index ce47e6df8..08439b7fe 100644 --- a/packages/@aws-cdk/cloud-assembly-api/lib/assembly-index.ts +++ b/packages/@aws-cdk/cloud-assembly-api/lib/assembly-index.ts @@ -1,6 +1,14 @@ import * as fs from 'fs'; import * as path from 'path'; -import { type ViolatingConstructJson, ArtifactMetadataEntryType, CFN_RESOURCE_LOGICAL_ID_ATTRIBUTE, CFN_RESOURCE_PROPS_ATTRIBUTE, CFN_RESOURCE_TYPE_ATTRIBUTE, type MetadataEntry } from '@aws-cdk/cloud-assembly-schema'; +import { + type ViolatingConstructJson, + ArtifactMetadataEntryType, + CFN_RESOURCE_LOGICAL_ID_ATTRIBUTE, + CFN_RESOURCE_PROPS_ATTRIBUTE, + CFN_RESOURCE_TYPE_ATTRIBUTE, + type MetadataEntry, + type PropertyMutationMetadataEntry, +} from '@aws-cdk/cloud-assembly-schema'; import type { CloudFormationStackArtifact } from './artifacts/cloudformation-artifact'; import type { CloudAssembly } from './cloud-assembly'; import { type ForestFile, type TreeNode, type TreeFile, type TreeFileNode, isSubtreeReference, type ConstructInfo } from './tree-json-types'; @@ -17,6 +25,15 @@ export interface AssemblyIndexNode { readonly children: Record; readonly creationStack?: string[]; + /** + * Stack traces of property assignments and mutations, keyed by CloudFormation property name. + * + * Includes all recorded mutation sites for each property, in metadata order. + * Only available when the assembly contains property assignment metadata, + * which aws-cdk-lib emits in debug mode. + */ + readonly propertyMutationStacks?: Record; + /** * Construct class, if available from `tree.json`. */ @@ -231,7 +248,12 @@ export class AssemblyIndex { } break; - // TODO: property mutations + case ArtifactMetadataEntryType.PROPERTY_ASSIGNMENT: { + const { propertyName, stackTrace } = entry.data as PropertyMutationMetadataEntry; + const mutations = mutable(node).propertyMutationStacks ??= Object.create(null) as Record; + (mutations[propertyName] ??= []).push(stackTrace); + break; + } } } } diff --git a/packages/@aws-cdk/cloud-assembly-api/test/assembly-index.test.ts b/packages/@aws-cdk/cloud-assembly-api/test/assembly-index.test.ts index 0c1a4ca1d..e747bf8fd 100644 --- a/packages/@aws-cdk/cloud-assembly-api/test/assembly-index.test.ts +++ b/packages/@aws-cdk/cloud-assembly-api/test/assembly-index.test.ts @@ -32,7 +32,7 @@ async function indexOf(assemblyDir: string): Promise { * A single-stack assembly: stack `S` with an L2 `Bucket` wrapping an L1 * `Resource`, plus the internal `CDKMetadata` node every real app carries. */ -function singleStackAssembly(opts: { withTree?: boolean } = {}): string { +function singleStackAssembly(opts: { withTree?: boolean; propertyMutations?: { propertyName: string; stackTrace: string[] }[] } = {}): string { const withTree = opts.withTree ?? true; return writeFiles({ 'manifest.json': { @@ -43,7 +43,10 @@ function singleStackAssembly(opts: { withTree?: boolean } = {}): string { environment: 'aws://111/us-east-1', properties: { templateFile: 'S.template.json' }, metadata: { - '/S/Bucket/Resource': [{ type: 'aws:cdk:logicalId', data: 'BucketABC' }], + '/S/Bucket/Resource': [ + { type: 'aws:cdk:logicalId', data: 'BucketABC' }, + ...(opts.propertyMutations ?? []).map((data) => ({ type: 'aws:cdk:propertyAssignment', data })), + ], '/S/CDKMetadata/Default': [{ type: 'aws:cdk:logicalId', data: 'CDKMetadata' }], }, }, @@ -81,6 +84,48 @@ function singleStackAssembly(opts: { withTree?: boolean } = {}): string { }); } +describe('property mutation stacks', () => { + test.each([true, false])('indexes mutation sites with tree.json present: %s', async (withTree) => { + dir = singleStackAssembly({ + withTree, + propertyMutations: [ + { propertyName: 'Tags', stackTrace: ['at addTag (app.ts:10:3)', 'at main (app.ts:20:1)'] }, + { propertyName: 'BucketName', stackTrace: ['at main (app.ts:8:1)'] }, + { propertyName: 'Tags', stackTrace: ['at addTag (app.ts:11:3)'] }, + ], + }); + const index = await indexOf(dir); + const node = index.byPath('/S/Bucket/Resource')!; + + expect(node.propertyMutationStacks).toEqual({ + Tags: [['at addTag (app.ts:10:3)', 'at main (app.ts:20:1)'], ['at addTag (app.ts:11:3)']], + BucketName: [['at main (app.ts:8:1)']], + }); + expect(index.byTemplateLocation('S.template.json', 'BucketABC')).toBe(node); + expect(index.byPath('S/Bucket')!.propertyMutationStacks).toBeUndefined(); + }); + + test('leaves mutation stacks absent when debug metadata is unavailable', async () => { + dir = singleStackAssembly(); + expect((await indexOf(dir)).byPath('S/Bucket/Resource')!.propertyMutationStacks).toBeUndefined(); + }); + + test('preserves repeated and empty traces and handles property names matching object members', async () => { + dir = singleStackAssembly({ + propertyMutations: [ + { propertyName: 'Tags', stackTrace: ['at main (app.ts:10:1)'] }, + { propertyName: 'Tags', stackTrace: ['at main (app.ts:10:1)'] }, + { propertyName: '__proto__', stackTrace: [] }, + { propertyName: 'constructor', stackTrace: ['at main (app.ts:12:1)'] }, + ], + }); + const mutations = (await indexOf(dir)).byPath('S/Bucket/Resource')!.propertyMutationStacks!; + expect(mutations.Tags).toEqual([['at main (app.ts:10:1)'], ['at main (app.ts:10:1)']]); + expect(mutations.__proto__).toEqual([[]]); + expect(mutations.constructor).toEqual([['at main (app.ts:12:1)']]); + }); +}); + describe('tree.json loading', () => { test('joins tree.json attributes with manifest metadata onto one node', async () => { dir = singleStackAssembly(); @@ -247,7 +292,10 @@ describe('nested stacks', () => { properties: { templateFile: 'S.template.json' }, metadata: { '/S/TopBucket/Resource': [{ type: 'aws:cdk:logicalId', data: 'TopBucket' }], - '/S/N/Queue/Resource': [{ type: 'aws:cdk:logicalId', data: nestedLogicalId }], + '/S/N/Queue/Resource': [ + { type: 'aws:cdk:logicalId', data: nestedLogicalId }, + { type: 'aws:cdk:propertyAssignment', data: { propertyName: 'VisibilityTimeout', stackTrace: ['nestedMutation'] } }, + ], '/S/N.NestedStack/N.NestedStackResource': [{ type: 'aws:cdk:logicalId', data: 'NNestedStackRes' }], }, }, @@ -277,6 +325,14 @@ describe('nested stacks', () => { expect(index.byPath('S/N/Queue/Resource')!.cfnResource.assemblyRelativeTemplateFile).toBe('SNInner.nested.template.json'); }); + test('exposes nested resource mutation sites through template lookup', async () => { + dir = nestedStackAssembly(); + const index = await indexOf(dir); + expect(index.byTemplateLocation('SNInner.nested.template.json', 'NestedQueue')!.propertyMutationStacks) + .toEqual({ VisibilityTimeout: [['nestedMutation']] }); + expect(index.byTemplateLocation('S.template.json', 'TopBucket')!.propertyMutationStacks).toBeUndefined(); + }); + test('routes twins that share a logical id to their own templates', async () => { // A NestedStack resets the logical-ID namespace, so the parent bucket and the // nested queue can both be "TopBucket"; scope must disambiguate, not the id. @@ -382,7 +438,10 @@ describe('nested assemblies (Stages)', () => { displayName: 'Prod/Service', metadata: { '/Prod/Service/Bucket/Resource': [{ type: 'aws:cdk:logicalId', data: 'SvcBucket' }], - '/Prod/Service/Inner/Queue/Resource': [{ type: 'aws:cdk:logicalId', data: 'InnerQueue' }], + '/Prod/Service/Inner/Queue/Resource': [ + { type: 'aws:cdk:logicalId', data: 'InnerQueue' }, + { type: 'aws:cdk:propertyAssignment', data: { propertyName: 'VisibilityTimeout', stackTrace: ['stageMutation'] } }, + ], }, }, }, @@ -420,6 +479,14 @@ describe('nested assemblies (Stages)', () => { .toBe('assembly-Prod/ProdServiceInner.nested.template.json'); }); + test('exposes mutation sites inside a nested assembly', async () => { + dir = stageAssembly(); + const index = await indexOf(dir); + expect(index.byTemplateLocation('assembly-Prod/ProdServiceInner.nested.template.json', 'InnerQueue')!.propertyMutationStacks) + .toEqual({ VisibilityTimeout: [['stageMutation']] }); + expect(index.byPath('Root/Bucket/Resource')!.propertyMutationStacks).toBeUndefined(); + }); + test('byTemplateLocation round-trips the root-relative path it reports', async () => { dir = stageAssembly(); const index = await indexOf(dir);