Skip to content

Commit 9571c5a

Browse files
authored
Merge branch 'main' into dependabot/github_actions/actions/github-script-9
2 parents c691026 + a47ad39 commit 9571c5a

6 files changed

Lines changed: 236 additions & 16 deletions

File tree

AGENTS.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,12 +175,36 @@ Tokens can encode strings, numbers, and lists. Any object implementing `IResolva
175175
- Use `Tokenization.stringifyNumber()` to safely convert a possibly-tokenized number to string
176176
- Don't use resource attributes (Tokens) in hash calculations for physical names
177177

178-
### Lazy Values
178+
### Deferred Values (Box API)
179+
180+
L2 constructs that accumulate state after construction (e.g., adding actions, policy statements, security groups) MUST use the **Box API** to defer value resolution — not `Lazy`. Boxes implement `IResolvable` and capture stack traces at mutation call sites (under `CDK_DEBUG`), enabling accurate property attribution in synthesized templates.
181+
182+
- Use `Box.fromArray<T>([])` for accumulator lists, `Box.fromValue<T>(initial)` for single values, `Box.fromMap<K,V>()` for maps, `Box.fromSet<A>()` for sets
183+
- Pass to L1 props via `Token.asList(box)`, `Token.asString(box)`, `Token.asNumber(box)`, or `Token.asAny(box)` for complex/object values
184+
- `Box.fromArray` resolves to `undefined` when empty (omitEmpty default) — no manual empty-array check needed. Pass `{ omitEmpty: false }` to resolve to an empty array instead
185+
- Mutate via `box.push(item)` or `box.set(newValue)` — each captures a stack trace at the call site
186+
- Use `box.derive(fn)` for single-source transforms or `Box.combine({ name: box, ... }, ({ name, ... }) => ...)` for multi-source derived values
187+
- Apply `@noBoxStackTraces` decorator on L2 classes that create or mutate Boxes in their constructor (suppresses irrelevant internal traces)
188+
- NEVER mutate construct tree in Lazy or Box callbacks
189+
190+
`Lazy` is legacy — existing code still uses it but new L2 constructs MUST prefer Boxes. See `packages/aws-cdk-lib/core/adr/box-api.md` for full rationale.
191+
192+
**Before (legacy — do not use in new code):**
193+
```ts
194+
alarmActions: Lazy.list({ produce: () => this.alarmActionArns }),
195+
```
196+
197+
**After (preferred):**
198+
```ts
199+
protected readonly _alarmActionArns: IArrayBox<string> = Box.fromArray([]);
200+
// in constructor:
201+
alarmActions: Token.asList(this._alarmActionArns),
202+
// in mutating method:
203+
this._alarmActionArns.push(newArn); // stack trace captured here
204+
```
179205

180-
- `Lazy.any()` for arrays: pass `{ omitEmptyArray: true }`
181206
- Map empty arrays to `undefined` for CFN properties
182207
- Optional nested CFN objects: `undefined` (not `{}`) when no sub-properties set
183-
- NEVER mutate construct tree in Lazy callbacks
184208

185209
### ARN Construction
186210

docs/DESIGN_GUIDELINES.md

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2043,26 +2043,82 @@ information that can be obtained from the stack trace.
20432043

20442044
* Do not use FnSub
20452045

2046-
### Lazys
2046+
### Deferred Values
20472047

2048-
Do not use a `Lazy` to perform a mutation on the construct tree. For example:
2048+
#### Box API (preferred for new code)
2049+
2050+
L2 constructs that accumulate state after construction (e.g., alarm actions, policy
2051+
statements, security group rules) should use the **Box API** instead of `Lazy`.
2052+
Boxes are mutable containers that implement `IResolvable` and capture stack traces
2053+
at mutation call sites, enabling accurate property-to-source-code attribution.
2054+
2055+
**Before (Lazy — legacy, do not use in new code):**
2056+
2057+
```ts
2058+
class MyL2 extends Resource {
2059+
private readonly _actions: string[] = [];
2060+
2061+
constructor(scope: Construct, id: string) {
2062+
super(scope, id);
2063+
new CfnResource(this, 'Resource', {
2064+
// Stack trace captured HERE (constructor), not useful
2065+
actions: Lazy.list({ produce: () => this._actions }),
2066+
});
2067+
}
2068+
2069+
addAction(action: string) {
2070+
this._actions.push(action); // No stack trace captured
2071+
}
2072+
}
2073+
```
2074+
2075+
**After (Box — preferred):**
20492076

20502077
```ts
2051-
constructor(scope: Scope, id: string, props: MyConstructProps) {
2052-
this.lazyProperty = Lazy.any({
2053-
produce: () => {
2054-
return props.logging.bind(this, this);
2055-
},
2056-
});
2078+
@noBoxStackTraces
2079+
class MyL2 extends Resource {
2080+
private readonly _actions: IArrayBox<string> = Box.fromArray([]);
2081+
2082+
constructor(scope: Construct, id: string) {
2083+
super(scope, id);
2084+
new CfnResource(this, 'Resource', {
2085+
actions: Token.asList(this._actions),
2086+
});
2087+
}
2088+
2089+
addAction(action: string) {
2090+
this._actions.push(action); // Stack trace captured HERE — user's code
2091+
}
20572092
}
20582093
```
20592094

2060-
`bind()` methods mutate the construct tree, and should not be called from a callback
2061-
in a `Lazy`.
2095+
Key rules:
2096+
- `Box.fromArray([])` for lists, `Box.fromValue(x)` for scalars, `Box.fromMap()` for maps, `Box.fromSet()` for sets
2097+
- Pass to L1 via `Token.asList(box)`, `Token.asString(box)`, `Token.asNumber(box)`, or `Token.asAny(box)` for complex/object values
2098+
- `Box.fromArray` resolves to `undefined` when empty by default (no manual empty-array → `undefined` mapping needed). Pass `{ omitEmpty: false }` as the second argument to resolve to an empty array instead
2099+
- Apply `@noBoxStackTraces` on classes that create or mutate Boxes in their constructor
2100+
- Use `box.derive(fn)` for single-source transforms, `Box.combine({ a: boxA, b: boxB }, ({ a, b }) => ...)` for multi-source derived values
2101+
2102+
See `packages/aws-cdk-lib/core/adr/box-api.md` for the full ADR.
20622103

2063-
* The why:
2064-
- `Lazy`s are called after the construct tree has already been sythesized. Mutating it
2065-
at this point could have not-obvious consequences.
2104+
#### Lazy (legacy)
2105+
2106+
`Lazy` remains available and is not deprecated, but new L2 constructs should prefer
2107+
Boxes for better debuggability. If you must use `Lazy`:
2108+
2109+
- Do not use a `Lazy` to perform a mutation on the construct tree
2110+
- `Lazy`s are resolved after the construct tree has been synthesized — mutating it
2111+
at that point has non-obvious consequences
2112+
- For `Lazy.any()` wrapping arrays, pass `{ omitEmptyArray: true }` to resolve empty arrays to `undefined`
2113+
2114+
```ts
2115+
// DO NOT do this — bind() mutates the construct tree
2116+
this.lazyProperty = Lazy.any({
2117+
produce: () => {
2118+
return props.logging.bind(this, this); // BAD: tree mutation in Lazy
2119+
},
2120+
});
2121+
```
20662122

20672123
## Documentation
20682124

packages/aws-cdk-lib/aws-events/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,4 +379,6 @@ const bus = new EventBus(this, 'Bus', {
379379
});
380380
```
381381

382+
**Note**: Configuring logging on the event bus is required when using [vended logs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AWS-logs-and-resource-policy.html). Vended logs require that the event bus has logging enabled with the appropriate log configuration before logs can be delivered to the destination.
383+
382384
See more [Specifying event bus log level](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-bus-logs.html#eb-event-bus-logs-level)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { IConstruct } from 'constructs';
2+
import { iterateDfsPreorder } from './construct-iteration';
3+
4+
/**
5+
* Collect all acknowledged rule IDs from construct metadata across the tree.
6+
*/
7+
export function collectAcknowledgedRuleIds(root: IConstruct): Set<string> {
8+
const ids = new Set<string>();
9+
for (const construct of iterateDfsPreorder(root)) {
10+
for (const entry of construct.node.metadata) {
11+
if (entry.type === 'aws:cdk:acknowledged-rules' && entry.data) {
12+
for (const id of Object.keys(entry.data as Record<string, string>)) {
13+
ids.add(id);
14+
}
15+
}
16+
}
17+
}
18+
return ids;
19+
}

packages/aws-cdk-lib/core/lib/private/synthesis.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as fs from 'fs';
22
import * as path from 'path';
33
import * as private_cxapi from '@aws-cdk/cloud-assembly-api';
44
import type { IConstruct } from 'constructs';
5+
import { collectAcknowledgedRuleIds } from './collect-acknowledged-rule-ids';
56
import { iterateDfsPreorder } from './construct-iteration';
67
import { generateFeatureFlagReport } from './feature-flag-report';
78
import { lit } from './literal-string';
@@ -265,6 +266,32 @@ function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: pri
265266
}
266267
}
267268

269+
// Filter out suppressed violations. Collect all acknowledged rule IDs
270+
// from construct metadata across the tree, then remove matching violations
271+
// from reports. Fatal violations cannot be suppressed.
272+
//
273+
// Rule matching: violations are matched as <pluginName>::<ruleName> with
274+
// spaces replaced by dashes. Users suppress with:
275+
// Validations.of(x).acknowledge({ id: '<plugin-name>::<rule-id>' })
276+
const acknowledgedRuleIds = collectAcknowledgedRuleIds(root);
277+
if (acknowledgedRuleIds.size > 0) {
278+
for (let i = 0; i < reports.length; i++) {
279+
const pluginName = reports[i].pluginName.replace(/ /g, '-');
280+
const filtered = reports[i].violations.filter(v => {
281+
if (v.severity === 'fatal') return true;
282+
const ruleId = `${pluginName}::${v.ruleName.replace(/ /g, '-')}`;
283+
return !acknowledgedRuleIds.has(ruleId);
284+
});
285+
if (filtered.length !== reports[i].violations.length) {
286+
reports[i] = {
287+
...reports[i],
288+
violations: filtered,
289+
success: filtered.every(v => v.severity !== 'error' && v.severity !== 'fatal'),
290+
};
291+
}
292+
}
293+
}
294+
268295
if (reports.length > 0) {
269296
const tree = new ConstructTree(root);
270297
const formatter = new PolicyValidationReportFormatter(tree);

packages/aws-cdk-lib/core/test/validation/validation.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,98 @@ Policy Validation Report Summary
10491049
const output = consoleErrorMock.mock.calls.map((c: any[]) => c[0]).join('\n');
10501050
expect(output).toContain('my-lib:TestId (');
10511051
});
1052+
1053+
test('plugin violations can be suppressed via Validations.acknowledge()', () => {
1054+
const app = new core.App({ context: annotationReportContext });
1055+
const stack = new core.Stack(app);
1056+
new core.CfnResource(stack, 'MyBucket', {
1057+
type: 'AWS::S3::Bucket',
1058+
properties: {},
1059+
});
1060+
1061+
core.Validations.of(app).addPlugins(
1062+
new FakePlugin('test-plugin', [{
1063+
description: 'S3 Bucket should have versioning enabled',
1064+
ruleName: 'S3_BUCKET_VERSIONING_ENABLED',
1065+
severity: 'error',
1066+
violatingResources: [{
1067+
locations: ['Properties/VersioningConfiguration'],
1068+
resourceLogicalId: 'MyBucket',
1069+
templatePath: '/path/to/Default.template.json',
1070+
}],
1071+
}]),
1072+
);
1073+
1074+
// Suppress the error-level violation using <pluginName>::<ruleId>
1075+
core.Validations.of(stack).acknowledge({ id: 'test-plugin::S3_BUCKET_VERSIONING_ENABLED', reason: 'Not needed for this bucket' });
1076+
1077+
app.synth();
1078+
1079+
const output = consoleErrorMock.mock.calls.map((c: any[]) => c[0]).join('\n');
1080+
expect(output).not.toContain('S3_BUCKET_VERSIONING_ENABLED');
1081+
});
1082+
1083+
test('fatal plugin violations cannot be suppressed', () => {
1084+
const app = new core.App({ context: annotationReportContext });
1085+
const stack = new core.Stack(app);
1086+
new core.CfnResource(stack, 'Fake', {
1087+
type: 'AWS::S3::Bucket',
1088+
properties: {},
1089+
});
1090+
1091+
core.Validations.of(app).addPlugins(
1092+
new FakePlugin('test-plugin', [{
1093+
description: 'Unknown resource type',
1094+
ruleName: 'E9001',
1095+
severity: 'fatal',
1096+
violatingResources: [{
1097+
locations: [],
1098+
resourceLogicalId: 'BadResource',
1099+
templatePath: '/path/to/Default.template.json',
1100+
}],
1101+
}]),
1102+
);
1103+
1104+
// Attempt to suppress the fatal violation
1105+
core.Validations.of(stack).acknowledge({ id: 'test-plugin::E9001', reason: 'Trying to suppress fatal' });
1106+
1107+
app.synth();
1108+
1109+
const output = consoleErrorMock.mock.calls.map((c: any[]) => c[0]).join('\n');
1110+
// Fatal violations remain despite acknowledgment
1111+
expect(output).toContain('E9001');
1112+
expect(output).toContain('Unknown resource type');
1113+
});
1114+
1115+
test('plugin names with spaces use dashes in suppression IDs', () => {
1116+
const app = new core.App({ context: annotationReportContext });
1117+
const stack = new core.Stack(app);
1118+
new core.CfnResource(stack, 'Fake', {
1119+
type: 'AWS::S3::Bucket',
1120+
properties: {},
1121+
});
1122+
1123+
core.Validations.of(app).addPlugins(
1124+
new FakePlugin('My Plugin', [{
1125+
description: 'Some violation',
1126+
ruleName: 'MY RULE',
1127+
severity: 'error',
1128+
violatingResources: [{
1129+
locations: [],
1130+
resourceLogicalId: 'Fake',
1131+
templatePath: '/path/to/Default.template.json',
1132+
}],
1133+
}]),
1134+
);
1135+
1136+
// Suppress using dashes instead of spaces
1137+
core.Validations.of(stack).acknowledge({ id: 'My-Plugin::MY-RULE', reason: 'OK' });
1138+
1139+
app.synth();
1140+
1141+
const output = consoleErrorMock.mock.calls.map((c: any[]) => c[0]).join('\n');
1142+
expect(output).not.toContain('MY RULE');
1143+
});
10521144
});
10531145

10541146
describe('Validations.of()', () => {

0 commit comments

Comments
 (0)