Skip to content

Commit 75893d9

Browse files
authored
fix(aws-cdk-lib): validation namespaces for annotations are inconsistent (#38256)
Validation rules are always prefixed with their plugin name, and Annotations are converted to Validations with a plugin named `"Construct Annotations"`. This means that added via `Annotations.of(...).addWarningV2` would get reported with a suppression string of `Construct-Annotations::<warning-id>`. It's a bit ugly because the `Construct-Annotations` string is quite large, but so far so good. However, `Validations.of(...).addWarning()` would require its IDs to be namespaced, and add a `annotations::` prefix in front if the ID was not namespaced yet. It would then use the Annotations system to publish that marker. The end result is that warnings added via `Validations.addWarning` would get reported as ``` Construct-Annotations::annotation::<warning-id> ``` This PR is taking the opportunity to polish that up a little bit before we fully release this new feature: - The *plugin name* needs to remain `"Construct Annotations"`, because the CLI will look for the annotations report in the output by its plugin name - But we added an metadata field so that the CLI can use structured information in the future to find this report. - We add the convention that if a rule name is already namespaced `<prefix>::<rule-name>`, then that embedded prefix takes precedence over the plugin name in acknowledgements. - Rename `annotation::` to `Annotation::` to be more consistent in casing with other plugins which all use camelcased names (keep supporting suppressions under the lowercased name). - We effectively renamed all suppression keys for construct annotations, so we continue to support both `Construct-Annotations::<rule>` and `Annotation::<rule>` as a suppression key for annotations. Also: * Move `collectAnnotationReport` to the same file as the `AnnotationPlugin` that it is doing its work for. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 4aa7e78 commit 75893d9

7 files changed

Lines changed: 227 additions & 153 deletions

File tree

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,123 @@
1+
import * as path from 'path';
2+
import type { IConstruct } from 'constructs';
13
import type { IPolicyValidationPlugin, IPolicyValidationContext } from '../validation';
4+
import { iterateDfsPreorder } from './construct-iteration';
5+
import * as cxschema from '../../../cloud-assembly-schema';
6+
import { Stack } from '../stack';
27
import type { NamedValidationPluginReport } from '../validation/private/report';
3-
import type { PolicyValidationPluginReport } from '../validation/report';
8+
import type { PolicyValidationPluginReport, PolicyViolation, PolicyViolatingResource } from '../validation/report';
49

510
/**
611
* Wraps the annotation collection logic as an IPolicyValidationPlugin
712
* so it can be run through the same unified plugin loop.
13+
*
14+
* Because the released version of this logic had a version where this plugin
15+
* was called 'Construct Annotations', and the CLI looked for the report issued
16+
* by this plugin by name, we need to keep the name stable for backwards
17+
* compatibility.
18+
*
19+
* However, the display title and acknowledgement title of annotations issued
20+
* by this plugin is now `Annotation::<rule-name>`, *unless* the annotation ID already
21+
* has a prefix in which case the prefix is preserved.
822
*/
923
export class AnnotationPlugin implements IPolicyValidationPlugin {
10-
public readonly name = 'Construct Annotations';
24+
public static RULE_PREFIX = 'Annotation';
25+
public static NAME = 'Construct Annotations';
26+
public readonly name = AnnotationPlugin.NAME;
1127

1228
constructor(private readonly report: NamedValidationPluginReport) {}
1329

1430
public validate(_context: IPolicyValidationContext): PolicyValidationPluginReport {
1531
return this.report;
1632
}
1733
}
34+
35+
/**
36+
* Collect annotation metadata (warnings and errors) from the construct tree
37+
* and convert them into a NamedValidationPluginReport that can be merged
38+
* into the same report pipeline as plugin violations.
39+
*/
40+
export function collectAnnotationReport(root: IConstruct, outdir: string): IPolicyValidationPlugin | undefined {
41+
const violationMap = new Map<string, PolicyViolation & { violatingResources: PolicyViolatingResource[] }>();
42+
43+
for (const construct of iterateDfsPreorder(root)) {
44+
for (const entry of construct.node.metadata) {
45+
if (entry.type !== cxschema.ArtifactMetadataEntryType.WARN && entry.type !== cxschema.ArtifactMetadataEntryType.ERROR) {
46+
continue;
47+
}
48+
49+
const severity = entry.type === cxschema.ArtifactMetadataEntryType.ERROR ? 'error' : 'warning';
50+
let { message, ruleName } = splitDescriptionAndId(String(entry.data));
51+
52+
if (ruleName && !ruleName.includes('::')) {
53+
ruleName = `${AnnotationPlugin.RULE_PREFIX}::${ruleName}`;
54+
}
55+
56+
let templatePath: string | undefined;
57+
try {
58+
templatePath = path.join(outdir, Stack.of(construct).templateFile);
59+
} catch {
60+
// Construct is not inside a Stack
61+
}
62+
63+
const violatingResource: PolicyViolatingResource = {
64+
constructPath: construct.node.path,
65+
templatePath,
66+
locations: [],
67+
};
68+
69+
const key = `${ruleName}|${severity}|${message}`;
70+
const existing = violationMap.get(key);
71+
if (existing) {
72+
existing.violatingResources.push(violatingResource);
73+
} else {
74+
violationMap.set(key, {
75+
ruleName: ruleName ?? `${AnnotationPlugin.RULE_PREFIX}::${severity}-annotation`,
76+
description: message,
77+
severity,
78+
violatingResources: [violatingResource],
79+
ruleMetadata: {
80+
'cdk:annotation': 'true',
81+
},
82+
});
83+
}
84+
}
85+
}
86+
87+
const violations = Array.from(violationMap.values());
88+
if (violations.length === 0) {
89+
return undefined;
90+
}
91+
92+
const hasErrors = violations.some(v => v.severity === 'error');
93+
return new AnnotationPlugin({
94+
pluginName: AnnotationPlugin.NAME,
95+
success: !hasErrors,
96+
violations,
97+
metadata: {
98+
'cdk:annotations': 'true',
99+
},
100+
});
101+
}
102+
103+
/**
104+
* Annotations have IDs in two places:
105+
*
106+
* - Warnings have `[ack:<id>]` in the message.
107+
* - Errors have `(<namespace>::<id>)` in the message.
108+
*
109+
* Separate the rule name from the rest of the description.
110+
*/
111+
function splitDescriptionAndId(message: string): { message: string; ruleName?: string } {
112+
const ackMatch = message.match(/\[ack: ([^\]]+)\]/);
113+
if (ackMatch) {
114+
return { message: message.replace(ackMatch[0], '').trim(), ruleName: ackMatch[1] };
115+
}
116+
117+
const idMatch = message.match(/\(([^()]+::[^()]+)\)$/);
118+
if (idMatch) {
119+
return { message: message.replace(idMatch[0], '').trim(), ruleName: idMatch[1] };
120+
}
121+
122+
return { message };
123+
}

packages/aws-cdk-lib/core/lib/private/collect-annotation-report.ts

Lines changed: 0 additions & 92 deletions
This file was deleted.

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

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ import * as fs from 'fs';
33
import * as path from 'path';
44
import type * as private_cxapi from '@aws-cdk/cloud-assembly-api';
55
import type { IConstruct } from 'constructs';
6-
import { AnnotationPlugin } from './annotation-plugin';
6+
import { AnnotationPlugin, collectAnnotationReport } from './annotation-plugin';
77
import { collectAcknowledgedRuleIds } from './collect-acknowledged-rule-ids';
8-
import { collectAnnotationReport } from './collect-annotation-report';
98
import { lit } from './literal-string';
109
import * as cxapi from '../../../cx-api';
1110
import { _convertCloudAssemblyBuilder } from '../../../cx-api/lib/legacy-moved';
@@ -213,9 +212,9 @@ function pluginsToEvaluate(root: IConstruct, stage: Stage, stageAssembly: privat
213212

214213
// 3. Construct annotations (as a plugin, only if there are annotations to report)
215214
if (FeatureFlags.of(root).isEnabled(cxapi.ANNOTATIONS_IN_VALIDATION_REPORT)) {
216-
const annotationReport = collectAnnotationReport(stage, stageAssembly.directory);
217-
if (annotationReport) {
218-
ret.push(new AnnotationPlugin(annotationReport));
215+
const annotationsPlugin = collectAnnotationReport(stage, stageAssembly.directory);
216+
if (annotationsPlugin) {
217+
ret.push(annotationsPlugin);
219218
}
220219
}
221220

@@ -254,23 +253,39 @@ function collectSuppressions(root: App, reports: NamedValidationPluginReport[])
254253

255254
if (acknowledgedRules.size > 0) {
256255
for (let i = 0; i < reports.length; i++) {
257-
const pluginName = reports[i].pluginName.replace(/ /g, '-');
256+
const pluginName = reports[i].pluginName;
258257
const active: typeof reports[0]['violations'] = [];
259258
const suppressed: SuppressedViolation[] = [];
260259
for (const v of reports[i].violations) {
261260
if (!isSuppressibleViolation(v)) {
262261
active.push(v);
263262
continue;
264263
}
265-
const ruleId = `${pluginName}::${v.ruleName.replace(/ /g, '-')}`;
266-
const ack = acknowledgedRules.get(ruleId);
264+
265+
const ackIds: string[] = [];
266+
if (v.ruleName.includes('::')) {
267+
ackIds.push(v.ruleName);
268+
269+
// Annotations are special; we renamed the suppression namespace at one point
270+
// from "Construct-Annotations" to just "Annotation", and we also want
271+
// to support the naked-rule-name form for backwards compatibility.
272+
if (pluginName === AnnotationPlugin.NAME) {
273+
const unnamespacedPart = v.ruleName.split('::').slice(1).join('::');
274+
ackIds.push(`${pluginName}::${unnamespacedPart}`);
275+
}
276+
} else {
277+
ackIds.push(`${pluginName}::${v.ruleName}`);
278+
}
279+
280+
const ack = firstThat(ackIds.map(hyphenify), id => acknowledgedRules.get(id));
281+
267282
if (ack) {
268283
suppressed.push({
269284
...v,
270-
acknowledgedId: ruleId,
271-
reason: ack.reason,
272-
acknowledgedAt: ack.constructPath,
273-
acknowledgedStackTrace: ack.stackTrace,
285+
acknowledgedId: ack.key,
286+
reason: ack.value.reason,
287+
acknowledgedAt: ack.value.constructPath,
288+
acknowledgedStackTrace: ack.value.stackTrace,
274289
});
275290
} else {
276291
active.push(v);
@@ -507,3 +522,17 @@ function stripAnsi(x: string) {
507522
const re = new RegExp(pattern, 'g');
508523
return x.replaceAll(re, '');
509524
}
525+
526+
function firstThat<A, B>(xs: A[], predicate: (x: A) => B | undefined): { key: A; value: B } | undefined {
527+
for (const x of xs) {
528+
const value = predicate(x);
529+
if (value !== undefined) {
530+
return { key: x, value };
531+
}
532+
}
533+
return undefined;
534+
}
535+
536+
function hyphenify(x: string) {
537+
return x.replace(/ /g, '-');
538+
}

packages/aws-cdk-lib/core/lib/validation/private/modern-formatter.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function formatViolationBlock(fileRoot: string, v: FlattenedViolation): string {
6666
lines.push([
6767
Colorize.bold(getSeverityColor(v.severity)(sanitize(v.severity))),
6868
Colorize.bold(stripAckTag(sanitize(v.description))),
69-
Colorize.grey(`(${sanitize(v.pluginName)})`),
69+
Colorize.grey(`(${namespace(v)})`),
7070
].join(' '));
7171

7272
const constructInfo = formatConstructInfo(fileRoot, v.construct);
@@ -77,8 +77,7 @@ function formatViolationBlock(fileRoot: string, v: FlattenedViolation): string {
7777
}
7878

7979
if (isSuppressibleViolation(v)) {
80-
const ackId = `${sanitize(v.pluginName)}::${sanitize(v.ruleName)}`.replace(/ /g, '-');
81-
lines.push(` ${Colorize.grey(`Acknowledge with '${ackId}'`)}`);
80+
lines.push(` ${Colorize.grey(`Acknowledge with '${ackId(v)}'`)}`);
8281
} else {
8382
// If not acknowledgeable, we should still show the rule name for reference.
8483
lines.push(` ${Colorize.grey(`Rule ${sanitize(v.ruleName)}`)}`);
@@ -174,3 +173,11 @@ function isPluginFailure(r: PluginReportJson): PluginError | undefined {
174173
}
175174
return { error: r.metadata.error };
176175
}
176+
177+
function namespace(v: FlattenedViolation): string {
178+
return v.ruleName.includes('::') ? sanitize(v.ruleName.split('::')[0]) : sanitize(v.pluginName);
179+
}
180+
181+
function ackId(v: FlattenedViolation): string {
182+
return (v.ruleName.includes('::') ? sanitize(v.ruleName) : `${sanitize(v.pluginName)}::${sanitize(v.ruleName)}`).replace(/ /g, '-');
183+
}

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { IConstruct } from 'constructs';
22
import type { IPolicyValidationPlugin } from './validation';
33
import { Annotations } from '../annotations';
44
import { UnscopedValidationError } from '../errors';
5+
import { AnnotationPlugin } from '../private/annotation-plugin';
56
import { lit } from '../private/literal-string';
67
import { Stage } from '../stage';
78

@@ -47,16 +48,6 @@ export class Validations {
4748
return new Validations(scope);
4849
}
4950

50-
/**
51-
* Well-known prefix for annotation-based validation rules.
52-
*
53-
* Every validation source identifies itself via a prefix so that
54-
* `acknowledge()` can route suppressions to the correct handler.
55-
* The `::` delimiter is reserved for separating the prefix from the
56-
* rule name (e.g. `annotation::MyWarning`).
57-
*/
58-
private static readonly ANNOTATION_PREFIX = 'annotation';
59-
6051
private constructor(private readonly scope: IConstruct) {}
6152

6253
/**
@@ -146,9 +137,16 @@ export class Validations {
146137
if (parts.length > 2 || (parts.length === 2 && parts[0].length === 0)) {
147138
throw new UnscopedValidationError(lit`InvalidValidationId`, `Invalid validation rule ID '${id}'. The '::' delimiter is reserved for separating the prefix from the rule name (e.g. 'prefix::RuleName').`);
148139
}
149-
if (parts.length === 2) {
150-
return id;
140+
141+
if (parts.length === 1) {
142+
return `${AnnotationPlugin.RULE_PREFIX}::${id}`;
143+
}
144+
145+
if (parts[0] === 'annotation') {
146+
// Uppercase this
147+
return `${AnnotationPlugin.RULE_PREFIX}::${parts[1]}`;
151148
}
152-
return `${Validations.ANNOTATION_PREFIX}::${id}`;
149+
150+
return id;
153151
}
154152
}

0 commit comments

Comments
 (0)