Skip to content

Commit 4e09b52

Browse files
authored
feat(core): new validation report schema (#37970)
### Reason for this change The CDK CLI `validate` command (aws/aws-cdk-cli#1515) introduces a new validation report schema in `cloud-assembly-schema`. The framework needs to produce output matching this new schema so the CLI can consume it via `Manifest.loadValidationReport`. The old report format (`policy-validation-report.json`) must remain available for backwards compatibility with older CLI versions. ### Description of changes - Adds a new `formatJson` method to `PolicyValidationReportFormatter` that produces the new schema-compliant report format: - Top-level `version` field (cloud assembly schema version) - Flat `pluginName`/`conclusion` (replaces nested `summary`) - Typed `severity` enum (`fatal|error|warning|info|custom`) - `violatingConstructs` with `constructFqn`, `libraryVersion`, `cloudFormationResource`, `stackTraces` - `suggestedFix` (replaces `fix`) - New report is written to `validation-report.json` by default - Old report (`policy-validation-report.json`) is only written when `@aws-cdk/core:validationReportJson` context key is set to `true` - Renames old format interfaces to `Legacy*`, new ones get the clean names - Adds constants to `cx-api`: `VALIDATION_REPORT_FILE`, `LEGACY_VALIDATION_REPORT_FILE`, `VALIDATION_REPORT_JSON_CONTEXT` ### Describe any new or updated permissions being added N/A ### Description of how you validated changes - All 47 validation unit tests pass - Added tests for legacy report opt-in and default behavior - Verified report output passes JSON schema validation matching `validation-report.schema.json` from the CLI PR ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 8e25d78 commit 4e09b52

4 files changed

Lines changed: 274 additions & 78 deletions

File tree

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { ConstructTree } from '../validation/private/construct-tree';
2727
import type { NamedValidationPluginReport } from '../validation/private/report';
2828
import { PolicyValidationReportFormatter } from '../validation/private/report';
2929

30-
const POLICY_VALIDATION_FILE_PATH = 'policy-validation-report.json';
30+
const LEGACY_POLICY_VALIDATION_FILE_PATH = 'policy-validation-report.json';
3131

3232
/**
3333
* Options for `synthesize()`
@@ -198,9 +198,18 @@ function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: pri
198198
const tree = new ConstructTree(root);
199199
const formatter = new PolicyValidationReportFormatter(tree);
200200
const failOnErrors = root.node.tryGetContext(cxapi.FAIL_SYNTH_ON_VALIDATION_ERRORS_CONTEXT) ?? true;
201-
const reportFile = path.join(assembly.directory, POLICY_VALIDATION_FILE_PATH);
202-
const jsonOutput = formatter.formatJson(reports);
201+
const writeLegacyReport = root.node.tryGetContext(cxapi.VALIDATION_REPORT_JSON_CONTEXT) ?? false;
202+
203+
const reportFile = path.join(assembly.directory, cxapi.VALIDATION_REPORT_FILE);
204+
const jsonOutput = formatter.formatJson(reports, assembly.version);
203205
fs.writeFileSync(reportFile, JSON.stringify(jsonOutput, undefined, 2));
206+
207+
if (writeLegacyReport) {
208+
const legacyReportFile = path.join(assembly.directory, LEGACY_POLICY_VALIDATION_FILE_PATH);
209+
const legacyOutput = formatter.formatLegacyJson(reports);
210+
fs.writeFileSync(legacyReportFile, JSON.stringify(legacyOutput, undefined, 2));
211+
}
212+
204213
if (failOnErrors) {
205214
const output = formatter.formatPrettyPrinted(reports);
206215
// eslint-disable-next-line no-console

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

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export interface ValidationViolatingConstruct extends report.PolicyViolatingReso
3737
/**
3838
* JSON representation of the report.
3939
*/
40-
export interface PolicyValidationReportJson {
40+
export interface LegacyPolicyValidationReportJson {
4141
/**
4242
* Report title.
4343
*/
@@ -47,13 +47,13 @@ export interface PolicyValidationReportJson {
4747
* Reports for all of the validation plugins registered
4848
* in the app
4949
*/
50-
readonly pluginReports: PluginReportJson[];
50+
readonly pluginReports: LegacyPluginReportJson[];
5151
}
5252

5353
/**
5454
* A report from a single plugin
5555
*/
56-
export interface PluginReportJson {
56+
export interface LegacyPluginReportJson {
5757
/**
5858
* List of violations in the report.
5959
*/
@@ -62,7 +62,7 @@ export interface PluginReportJson {
6262
/**
6363
* Report summary.
6464
*/
65-
readonly summary: PolicyValidationReportSummary;
65+
readonly summary: LegacyPolicyValidationReportSummary;
6666

6767
/**
6868
* Plugin version.
@@ -73,7 +73,7 @@ export interface PluginReportJson {
7373
/**
7474
* Summary of the report.
7575
*/
76-
export interface PolicyValidationReportSummary {
76+
export interface LegacyPolicyValidationReportSummary {
7777
/**
7878
* The final status of the validation (pass/fail)
7979
*/
@@ -103,6 +103,51 @@ export interface NamedValidationPluginReport extends report.PolicyValidationPlug
103103
readonly pluginName: string;
104104
}
105105

106+
/**
107+
* JSON representation of the validation report, matching cloud-assembly-schema.
108+
*/
109+
export interface PolicyValidationReportJson {
110+
readonly version: string;
111+
readonly title?: string;
112+
readonly pluginReports: PluginReportJson[];
113+
}
114+
115+
export interface PluginReportJson {
116+
readonly pluginName: string;
117+
readonly pluginVersion?: string;
118+
readonly conclusion: PolicyValidationReportConclusion;
119+
readonly metadata?: { readonly [key: string]: string };
120+
readonly violations: PolicyViolationJson[];
121+
}
122+
123+
export type PolicyValidationReportConclusion = 'success' | 'failure';
124+
125+
export interface PolicyViolationJson {
126+
readonly ruleName: string;
127+
readonly description: string;
128+
readonly suggestedFix?: string;
129+
readonly severity: PolicyViolationSeverity;
130+
readonly customSeverity?: string;
131+
readonly ruleMetadata?: { readonly [key: string]: string };
132+
readonly violatingConstructs: ViolatingConstructJson[];
133+
}
134+
135+
export type PolicyViolationSeverity = 'fatal' | 'error' | 'warning' | 'info' | 'custom';
136+
137+
export interface ViolatingConstructJson {
138+
readonly constructPath: string;
139+
readonly constructFqn?: string;
140+
readonly libraryVersion?: string;
141+
readonly cloudFormationResource?: CloudFormationResourceJson;
142+
readonly stackTraces?: string[];
143+
}
144+
145+
export interface CloudFormationResourceJson {
146+
readonly templatePath: string;
147+
readonly logicalId: string;
148+
readonly propertyPaths?: string[];
149+
}
150+
106151
/**
107152
* The report emitted by the plugin after evaluation.
108153
*/
@@ -113,7 +158,7 @@ export class PolicyValidationReportFormatter {
113158
}
114159

115160
public formatPrettyPrinted(reps: NamedValidationPluginReport[]): string {
116-
const json = this.formatJson(reps);
161+
const json = this.formatLegacyJson(reps);
117162
const output = [json.title];
118163
output.push('-'.repeat(json.title.length));
119164

@@ -187,7 +232,7 @@ export class PolicyValidationReportFormatter {
187232
return output.join(os.EOL);
188233
}
189234

190-
public formatJson(reps: NamedValidationPluginReport[]): PolicyValidationReportJson {
235+
public formatLegacyJson(reps: NamedValidationPluginReport[]): LegacyPolicyValidationReportJson {
191236
return {
192237
title: 'Validation Report',
193238
pluginReports: reps
@@ -234,6 +279,92 @@ export class PolicyValidationReportFormatter {
234279
})),
235280
};
236281
}
282+
283+
public formatJson(reps: NamedValidationPluginReport[], schemaVersion: string): PolicyValidationReportJson {
284+
return {
285+
version: schemaVersion,
286+
title: 'Validation Report',
287+
pluginReports: reps
288+
.filter(rep => !rep.success || rep.violations.length > 0)
289+
.map(rep => ({
290+
pluginName: rep.pluginName,
291+
pluginVersion: rep.pluginVersion,
292+
conclusion: (rep.success ? 'success' : 'failure') as PolicyValidationReportConclusion,
293+
metadata: rep.metadata,
294+
violations: rep.violations.map(violation => {
295+
const severity = normalizeSeverity(violation.severity);
296+
return {
297+
ruleName: violation.ruleName,
298+
description: violation.description,
299+
suggestedFix: violation.fix,
300+
severity: severity.severity,
301+
customSeverity: severity.customSeverity,
302+
ruleMetadata: violation.ruleMetadata,
303+
violatingConstructs: violation.violatingResources.map(resource => {
304+
const constructPath = resource.constructPath ?? (
305+
resource.templatePath && resource.resourceLogicalId
306+
? this.tree.getConstructByLogicalId(
307+
path.basename(resource.templatePath),
308+
resource.resourceLogicalId,
309+
)?.node.path
310+
: undefined
311+
);
312+
const construct = constructPath
313+
? this.tree.getConstructByPath(constructPath)
314+
: undefined;
315+
const constructInfo = construct
316+
? this.tree.constructTraceLevelFromTreeNode(construct)
317+
: undefined;
318+
319+
const result: ViolatingConstructJson = {
320+
constructPath: constructPath ?? 'N/A',
321+
constructFqn: constructInfo?.construct,
322+
libraryVersion: constructInfo?.libraryVersion,
323+
cloudFormationResource: resource.resourceLogicalId && resource.templatePath
324+
? {
325+
templatePath: resource.templatePath,
326+
logicalId: resource.resourceLogicalId,
327+
propertyPaths: resource.locations.length > 0 ? resource.locations : undefined,
328+
}
329+
: undefined,
330+
stackTraces: constructPath
331+
? this.formatStackTraces(constructPath)
332+
: undefined,
333+
};
334+
return result;
335+
}),
336+
};
337+
}),
338+
})),
339+
};
340+
}
341+
342+
private formatStackTraces(constructPath: string): string[] | undefined {
343+
const trace = this.reportTrace.formatJson(constructPath);
344+
if (!trace) return undefined;
345+
const lines: string[] = [];
346+
let current: ConstructTrace | undefined = trace;
347+
while (current) {
348+
if (current.location) {
349+
lines.push(current.location);
350+
}
351+
current = current.child;
352+
}
353+
return lines.length > 0 ? [lines.join('\n')] : undefined;
354+
}
355+
}
356+
357+
const KNOWN_SEVERITIES = new Set(['fatal', 'error', 'warning', 'info']);
358+
359+
function normalizeSeverity(severity: string | undefined): { severity: PolicyViolationSeverity; customSeverity?: string } {
360+
if (!severity) {
361+
return { severity: 'error' };
362+
}
363+
const lower = severity.toLowerCase();
364+
if (KNOWN_SEVERITIES.has(lower)) {
365+
return { severity: lower as PolicyViolationSeverity };
366+
}
367+
return { severity: 'custom', customSeverity: severity };
237368
}
238369

239370
function reset(s: string) {

0 commit comments

Comments
 (0)