Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/@aws-cdk/cloud-assembly-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[][]> {
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`.
26 changes: 24 additions & 2 deletions packages/@aws-cdk/cloud-assembly-api/lib/assembly-index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,6 +25,15 @@ export interface AssemblyIndexNode {
readonly children: Record<string, AssemblyIndexNode>;
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<string, string[][]>;

/**
* Construct class, if available from `tree.json`.
*/
Expand Down Expand Up @@ -231,7 +248,12 @@ export class AssemblyIndex {
}
break;

// TODO: property mutations <https://github.com/aws/aws-cdk-cli/issues/2064>
case ArtifactMetadataEntryType.PROPERTY_ASSIGNMENT: {
const { propertyName, stackTrace } = entry.data as PropertyMutationMetadataEntry;
const mutations = mutable(node).propertyMutationStacks ??= Object.create(null) as Record<string, string[][]>;
(mutations[propertyName] ??= []).push(stackTrace);
break;
}
}
}
}
Expand Down
75 changes: 71 additions & 4 deletions packages/@aws-cdk/cloud-assembly-api/test/assembly-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ async function indexOf(assemblyDir: string): Promise<AssemblyIndex> {
* 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': {
Expand All @@ -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' }],
},
},
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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' }],
},
},
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'] } },
],
},
},
},
Expand Down Expand Up @@ -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);
Expand Down
Loading