From a343ccb9c9b3f6aa4ba0fc79425ff16b24e4028f Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Wed, 20 May 2026 18:31:22 -0400 Subject: [PATCH 1/8] feat(cli): `cdk validate` command (behind `--unstable` flag) Wire up the `cdk validate` command in the CLI package, gated behind the `--unstable=validate` flag. Adds: - CLI command routing, config, and user input parsing - Validate output formatting with severity-sorted violations - Integration test fixtures and tests - Multi-plugin validation fixture for formatter testing --- .../resources/cdk-apps/validate-app/app.js | 114 ++++++++++++++ .../resources/cdk-apps/validate-app/cdk.json | 7 + ...cknowledge-suppresses-warning.integtest.ts | 28 ++++ ...cdk-validate-passes-clean-app.integtest.ts | 15 ++ ...k-validate-reports-violations.integtest.ts | 24 +++ .../toolkit-lib/docs/message-registry.md | 6 +- .../lib/api/io/private/messages.ts | 6 +- .../lib/api/validate/validate-formatting.ts | 139 +++++++++++++++++ .../toolkit-lib/lib/toolkit/toolkit.ts | 13 +- .../cdk.out/manifest.json | 18 +++ .../cdk.out/policy-validation-report.json | 141 ++++++++++++++++++ .../cdk.out/test-validate.template.json | 12 ++ .../toolkit-lib/test/actions/validate.test.ts | 23 ++- packages/aws-cdk/lib/cli/cdk-toolkit.ts | 11 +- packages/aws-cdk/lib/cli/cli-config.ts | 8 + .../aws-cdk/lib/cli/cli-type-registry.json | 8 + packages/aws-cdk/lib/cli/cli.ts | 8 + .../aws-cdk/lib/cli/convert-to-user-input.ts | 8 + .../lib/cli/parse-command-line-arguments.ts | 1 + .../aws-cdk/lib/cli/user-configuration.ts | 1 + packages/aws-cdk/lib/cli/user-input.ts | 17 +++ 21 files changed, 581 insertions(+), 27 deletions(-) create mode 100644 packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js create mode 100644 packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-acknowledge-suppresses-warning.integtest.ts create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts create mode 100644 packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts create mode 100644 packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/manifest.json create mode 100644 packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/policy-validation-report.json create mode 100644 packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/test-validate.template.json diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js new file mode 100644 index 000000000..01a28f12a --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js @@ -0,0 +1,114 @@ +const cdk = require('aws-cdk-lib/core'); +const s3 = require('aws-cdk-lib/aws-s3'); + +const stackPrefix = process.env.STACK_NAME_PREFIX; +if (!stackPrefix) { + throw new Error('the STACK_NAME_PREFIX environment variable is required'); +} + +class SecurityPlugin { + constructor() { + this.name = 'SecurityPlugin'; + this.version = '2.1.0'; + } + + validate(context) { + return { + success: false, + violations: [ + { + ruleName: 'no-public-buckets', + description: 'S3 Buckets must not be publicly accessible', + fix: 'Set PublicAccessBlockConfiguration on the bucket', + severity: 'fatal', + violatingResources: context.templatePaths.map(templatePath => ({ + resourceLogicalId: 'MyBucket', + templatePath, + locations: ['/Resources/MyBucket/Properties/PublicAccessBlockConfiguration'], + })), + }, + { + ruleName: 'require-encryption', + description: 'S3 Buckets must have server-side encryption enabled', + fix: 'Add BucketEncryption property with SSE-S3 or SSE-KMS', + severity: 'error', + violatingResources: context.templatePaths.map(templatePath => ({ + resourceLogicalId: 'MyBucket', + templatePath, + locations: ['/Resources/MyBucket/Properties/BucketEncryption'], + })), + }, + { + ruleName: 'require-versioning', + description: 'S3 Buckets should have versioning enabled for data protection', + severity: 'warning', + violatingResources: context.templatePaths.map(templatePath => ({ + resourceLogicalId: 'MyBucket', + templatePath, + locations: ['/Resources/MyBucket/Properties/VersioningConfiguration'], + })), + }, + { + ruleName: 'consider-intelligent-tiering', + description: 'Consider using Intelligent-Tiering storage class for cost optimization', + severity: 'info', + violatingResources: context.templatePaths.map(templatePath => ({ + resourceLogicalId: 'MyBucket', + templatePath, + locations: ['/Resources/MyBucket/Properties/IntelligentTieringConfigurations'], + })), + }, + { + ruleName: 'org-tagging-policy', + description: 'Resource does not comply with organization tagging policy TG-0042', + severity: 'compliance', + violatingResources: context.templatePaths.map(templatePath => ({ + resourceLogicalId: 'MyBucket', + templatePath, + locations: ['/Resources/MyBucket/Properties/Tags'], + })), + }, + ], + }; + } +} + +class AlwaysPassesPlugin { + constructor() { + this.name = 'AlwaysPassesPlugin'; + this.version = '1.0.0'; + } + + validate(_context) { + return { + success: true, + violations: [], + }; + } +} + +const shouldFail = process.env.VALIDATION_SHOULD_FAIL === 'true'; +const shouldAcknowledge = process.env.VALIDATION_ACKNOWLEDGE === 'true'; + +const app = new cdk.App(); +cdk.Validations.of(app).addPlugins(shouldFail ? new SecurityPlugin() : new AlwaysPassesPlugin()); + +class ValidateStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + const bucket = new s3.Bucket(this, 'MyBucket', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // Construct Annotations plugin will pick up this warning + cdk.Validations.of(bucket).addWarning('bucket-no-lifecycle', 'This bucket has no lifecycle rules configured'); + + if (shouldAcknowledge) { + cdk.Validations.of(bucket).acknowledge({ id: 'bucket-no-lifecycle', reason: 'Lifecycle rules not needed for this use case' }); + } + } +} + +new ValidateStack(app, `${stackPrefix}-validate`); + +app.synth(); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json new file mode 100644 index 000000000..0947e711e --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json @@ -0,0 +1,7 @@ +{ + "app": "node app.js", + "versionReporting": false, + "context": { + "@aws-cdk/core:validationReportOnly": true + } +} diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-acknowledge-suppresses-warning.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-acknowledge-suppresses-warning.integtest.ts new file mode 100644 index 000000000..72474cb0a --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-acknowledge-suppresses-warning.integtest.ts @@ -0,0 +1,28 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate acknowledge suppresses warning', + withSpecificFixture('validate-app', async (fixture) => { + // Without acknowledgment, the annotation warning should appear + const withWarning = await fixture.cdk( + ['--unstable=validate', 'validate', fixture.fullStackName('validate')], + { + modEnv: { VALIDATION_SHOULD_FAIL: 'false', VALIDATION_ACKNOWLEDGE: 'false' }, + allowErrExit: true, + }, + ); + + expect(withWarning).toContain('This bucket has no lifecycle rules configured'); + + // With acknowledgment, the annotation warning should be suppressed + const acknowledged = await fixture.cdk( + ['--unstable=validate', 'validate', fixture.fullStackName('validate')], + { + modEnv: { VALIDATION_SHOULD_FAIL: 'false', VALIDATION_ACKNOWLEDGE: 'true' }, + }, + ); + + expect(acknowledged).not.toContain('This bucket has no lifecycle rules configured'); + expect(acknowledged).toContain('Policy validation passed. No violations found.'); + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts new file mode 100644 index 000000000..480c4633d --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts @@ -0,0 +1,15 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate passes for clean app', + withSpecificFixture('validate-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', fixture.fullStackName('validate')], + { + modEnv: { VALIDATION_SHOULD_FAIL: 'false' }, + }, + ); + + expect(output).toContain('Policy validation passed. No violations found.'); + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts new file mode 100644 index 000000000..b21c0d2a6 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts @@ -0,0 +1,24 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate reports violations', + withSpecificFixture('validate-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', fixture.fullStackName('validate')], + { + modEnv: { VALIDATION_SHOULD_FAIL: 'true', VALIDATION_ACKNOWLEDGE: 'false' }, + allowErrExit: true, + }, + ); + + // SecurityPlugin violations + expect(output).toContain('S3 Buckets must not be publicly accessible'); + expect(output).toContain('S3 Buckets must have server-side encryption enabled'); + expect(output).toContain('S3 Buckets should have versioning enabled for data protection'); + expect(output).toContain('SecurityPlugin'); + + // Construct Annotations plugin picks up the addWarning + expect(output).toContain('This bucket has no lifecycle rules configured'); + expect(output).toContain('Construct Annotations'); + }), +); diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index 2fba211b7..87b360cca 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -146,9 +146,9 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_I9500` | Stack diagnosis (no problems found) | `info` | {@link DiagnosedStack} | | `CDK_TOOLKIT_E9500` | Stack diagnosis (problems found) | `error` | {@link DiagnosedStack} | | `CDK_TOOLKIT_W9501` | Stack diagnosis (diagnosis could not be performed) | `warn` | {@link DiagnosedStack} | -| `CDK_TOOLKIT_I9600` | Validate passed with no problems | `info` | {@link ValidateResult} | -| `CDK_TOOLKIT_E9600` | Validate found problems | `error` | {@link ValidateResult} | -| `CDK_TOOLKIT_I9601` | No validation plugins configured | `info` | n/a | +| `CDK_TOOLKIT_I9600` | Policy validation passed | `info` | {@link ValidateResult} | +| `CDK_TOOLKIT_E9600` | Policy validation failed | `error` | {@link ValidateResult} | +| `CDK_TOOLKIT_I9601` | No policy validation report found | `info` | n/a | | `CDK_TOOLKIT_I0100` | Notices decoration (the header or footer of a list of notices) | `info` | n/a | | `CDK_TOOLKIT_W0101` | A notice that is marked as a warning | `warn` | n/a | | `CDK_TOOLKIT_E0101` | A notice that is marked as an error | `error` | n/a | diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index 9b33e4347..a63a51378 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -508,19 +508,19 @@ export const IO = { // validate (96xx) CDK_TOOLKIT_I9600: make.info({ code: 'CDK_TOOLKIT_I9600', - description: 'Validate passed with no problems', + description: 'Policy validation passed', interface: 'ValidateResult', }), CDK_TOOLKIT_E9600: make.error({ code: 'CDK_TOOLKIT_E9600', - description: 'Validate found problems', + description: 'Policy validation failed', interface: 'ValidateResult', }), CDK_TOOLKIT_I9601: make.info({ code: 'CDK_TOOLKIT_I9601', - description: 'No validation plugins configured', + description: 'No policy validation report found', }), // Notices diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts new file mode 100644 index 000000000..5429acfb3 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts @@ -0,0 +1,139 @@ +import * as chalk from 'chalk'; +import type { PluginReportJson, ViolatingConstructJson } from '@aws-cdk/cloud-assembly-schema'; +import type { ValidateResult } from '../../actions/validate'; +import type { ActionLessMessage } from '../io/private'; +import { IO } from '../io/private'; + +interface FlattenedViolation { + readonly severity: string; + readonly description: string; + readonly ruleName: string; + readonly pluginName: string; + readonly construct: ViolatingConstructJson; +} + +const SEVERITY_ORDER: Record = { + fatal: 0, + error: 1, + warning: 2, + info: 3, +}; + +export function hostMessageFromValidation(result: ValidateResult): ActionLessMessage { + return IO.CDK_TOOLKIT_I9600.msg(formatValidateResult(result), result); +} + +export function formatValidateResult(result: ValidateResult): string { + const violations = flattenViolations(result.pluginReports); + + if (violations.length === 0) { + return 'Policy validation passed. No violations found.'; + } + + violations.sort((a, b) => { + const aOrder = SEVERITY_ORDER[a.severity.toLowerCase()] ?? 4; + const bOrder = SEVERITY_ORDER[b.severity.toLowerCase()] ?? 4; + return aOrder - bOrder; + }); + + const blocks = violations.map((v) => formatViolationBlock(v)); + return blocks.join('\n\n'); +} + +function flattenViolations(pluginReports: PluginReportJson[]): FlattenedViolation[] { + const result: FlattenedViolation[] = []; + + for (const report of pluginReports) { + const pluginName = report.pluginName; + + for (const violation of report.violations) { + const severity = normalizeSeverity(violation.severity); + + for (const construct of violation.violatingConstructs) { + result.push({ severity, description: violation.description, ruleName: violation.ruleName, pluginName, construct }); + } + } + } + + return result; +} + +function normalizeSeverity(severity: string | undefined): string { + if (!severity) return 'Warning'; + const lower = severity.toLowerCase(); + if (lower === 'fatal') return 'Fatal'; + if (lower === 'error') return 'Error'; + if (lower === 'warning') return 'Warning'; + if (lower === 'info') return 'Info'; + return severity; +} + +function formatViolationBlock(v: FlattenedViolation): string { + const lines: string[] = []; + + const location = getLeafLocation(v.construct.stackTraces); + if (location) { + lines.push(location); + } + + const severityColor = getSeverityColor(v.severity); + const severityAndDesc = severityColor(chalk.bold(`${formatSeverityName(v.severity)} ${v.description}`)); + lines.push(`${severityAndDesc} ${v.pluginName}`); + + const constructInfo = formatConstructInfo(v.construct); + lines.push(` ${constructInfo}`); + + if (v.severity.toLowerCase() !== 'fatal') { + const ackId = `${v.pluginName}::${v.ruleName}`; + lines.push(` Acknowledge '${ackId}'`); + } + + return lines.join('\n'); +} + +function formatSeverityName(severity: string): string { + switch (severity.toLowerCase()) { + case 'fatal': return 'Fatal'; + case 'error': return 'Error'; + case 'warning': return 'Warning'; + case 'info': return 'Info'; + default: return severity; + } +} + +function getSeverityColor(severity: string): (str: string) => string { + switch (severity.toLowerCase()) { + case 'fatal': return chalk.red; + case 'error': return chalk.hex('#FFA500'); + case 'warning': return chalk.yellow; + case 'info': return chalk.yellow; + default: return chalk.yellow; + } +} + +function formatConstructInfo(construct: ViolatingConstructJson): string { + const parts: string[] = []; + const logicalId = construct.cloudFormationResource?.logicalId; + + if (construct.constructPath) { + parts.push(logicalId ? `${chalk.bold(construct.constructPath)} (${logicalId})` : chalk.bold(construct.constructPath)); + } else if (logicalId) { + parts.push(chalk.bold(logicalId)); + } + + if (construct.constructFqn) { + parts.push(construct.constructFqn); + } + + return parts.join(' '); +} + +function getLeafLocation(stackTraces: string[] | undefined): string | undefined { + if (!stackTraces || stackTraces.length === 0) return undefined; + const lastTrace = stackTraces[stackTraces.length - 1]; + const frames = lastTrace.split('\n'); + if (frames.length === 0) return undefined; + const leafFrame = frames[0]; + const match = leafFrame.match(/\((.+)\)$/); + return match ? match[1] : leafFrame; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 8e783a627..239c9f44f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1,7 +1,7 @@ import '../private/dispose-polyfill'; import * as path from 'node:path'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; -import type { FeatureFlagReportProperties } from '@aws-cdk/cloud-assembly-schema'; +import type { FeatureFlagReportProperties, PolicyValidationReportConclusion } from '@aws-cdk/cloud-assembly-schema'; import { ArtifactType, Manifest } from '@aws-cdk/cloud-assembly-schema'; import type { TemplateDiff } from '@aws-cdk/cloudformation-diff'; import * as chalk from 'chalk'; @@ -57,7 +57,7 @@ import type { PublishAssetsOptions, PublishAssetsResult } from '../actions/publi import type { RefactorOptions } from '../actions/refactor'; import { type RollbackOptions } from '../actions/rollback'; import { type SynthOptions } from '../actions/synth'; -import type { ValidateOptions, ValidateResult, PolicyValidationReportConclusion } from '../actions/validate'; +import type { ValidateOptions, ValidateResult } from '../actions/validate'; import type { IWatcher, WatchOptions } from '../actions/watch'; import { countAssemblyResults } from './private/count-assembly-results'; import { WATCH_EXCLUDE_DEFAULTS } from '../actions/watch/private'; @@ -89,6 +89,7 @@ import type { ElapsedTime, IoHelper } from '../api/io/private'; import { asIoHelper, IO, SPAN, withoutColor, withoutEmojis, withTrimmedWhitespace } from '../api/io/private'; import { CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../api/logs-monitor'; import { ResourceOrphaner } from '../api/orphan/orphaner'; +import { hostMessageFromValidation } from '../api/validate/validate-formatting'; import { parseAndValidateConstructPaths } from '../api/orphan/private/helpers'; import { Mode, PluginHost } from '../api/plugin'; import { @@ -181,7 +182,7 @@ export interface ToolkitOptions { * Names of toolkit features that are still under development, and may change in * the future. */ -export type UnstableFeature = 'refactor' | 'orphan' | 'flags' | 'publish-assets' | 'diagnose'; +export type UnstableFeature = 'refactor' | 'orphan' | 'flags' | 'publish-assets' | 'diagnose' | 'validate'; /** * The AWS CDK Programmatic Toolkit @@ -686,11 +687,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { pluginReports: report.pluginReports, }; - if (conclusion === 'failure') { - await ioHelper.notify(IO.CDK_TOOLKIT_E9600.msg('❌ cdk validate found problems', result)); - } else { - await ioHelper.notify(IO.CDK_TOOLKIT_I9600.msg('✅ No problems found', result)); - } + await ioHelper.notify(hostMessageFromValidation(result)); return result; } diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/manifest.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/manifest.json new file mode 100644 index 000000000..f816c0779 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/manifest.json @@ -0,0 +1,18 @@ +{ + "version": "40.0.0", + "artifacts": { + "test-validate.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "test-validate.assets.json" + } + }, + "test-validate": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "test-validate.template.json" + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/policy-validation-report.json new file mode 100644 index 000000000..9b722a055 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/policy-validation-report.json @@ -0,0 +1,141 @@ +{ + "version": "1.0.0", + "title": "Validation Report", + "pluginReports": [ + { + "pluginName": "SecurityPlugin", + "pluginVersion": "2.1.0", + "conclusion": "failure", + "violations": [ + { + "ruleName": "no-public-buckets", + "description": "S3 Buckets must not be publicly accessible", + "suggestedFix": "Set PublicAccessBlockConfiguration on the bucket", + "severity": "fatal", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/PublicAccessBlockConfiguration"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + }, + { + "ruleName": "require-encryption", + "description": "S3 Buckets must have server-side encryption enabled", + "suggestedFix": "Add BucketEncryption property with SSE-S3 or SSE-KMS", + "severity": "error", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/BucketEncryption"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + }, + { + "ruleName": "require-versioning", + "description": "S3 Buckets should have versioning enabled for data protection", + "severity": "warning", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/VersioningConfiguration"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + }, + { + "ruleName": "consider-intelligent-tiering", + "description": "Consider using Intelligent-Tiering storage class for cost optimization", + "severity": "info", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/IntelligentTieringConfigurations"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + }, + { + "ruleName": "org-tagging-policy", + "description": "Resource does not comply with organization tagging policy TG-0042", + "severity": "custom", + "customSeverity": "compliance", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/Tags"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + } + ] + }, + { + "pluginName": "Construct Annotations", + "conclusion": "failure", + "violations": [ + { + "ruleName": "bucket-no-lifecycle", + "description": "This bucket has no lifecycle rules configured", + "severity": "warning", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket" + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + } + ] + } + ] +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/test-validate.template.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/test-validate.template.json new file mode 100644 index 000000000..b4fd39619 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/test-validate.template.json @@ -0,0 +1,12 @@ +{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts index 38c9ad239..2303a15b8 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -44,18 +44,11 @@ describe('validate', () => { ioHost.expectMessage({ containing: 'No validation plugins configured', level: 'info' }); }); - test('emits error IO message on failure', async () => { - const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - await toolkit.validate(cx); - - ioHost.expectMessage({ containing: 'cdk validate found problems', level: 'error' }); - }); - test('emits info IO message on success', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-passing-validation'); await toolkit.validate(cx); - ioHost.expectMessage({ containing: 'No problems found', level: 'info' }); + ioHost.expectMessage({ containing: 'No violations found', level: 'info' }); }); test('can invoke without options', async () => { @@ -95,6 +88,12 @@ describe('validate', () => { expect(result.pluginReports[1].pluginVersion).toBeUndefined(); }); + test('throws on malformed report', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-malformed-validation-report'); + + await expect(toolkit.validate(cx)).rejects.toThrow(); + }); + test('parses stack traces correctly', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); const result = await toolkit.validate(cx); @@ -109,11 +108,11 @@ describe('validate', () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); await toolkit.validate(cx); - const errorMsg = ioHost.messages.find( - (m) => m.code === 'CDK_TOOLKIT_E9600', + const msg = ioHost.messages.find( + (m) => m.code === 'CDK_TOOLKIT_I9600', ); - expect(errorMsg).toBeDefined(); - expect(errorMsg!.data).toMatchObject({ + expect(msg).toBeDefined(); + expect(msg!.data).toMatchObject({ conclusion: 'failure', title: 'Validation Report', pluginReports: expect.arrayContaining([ diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 8b7b1f4d6..365115d73 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { format } from 'node:util'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; import { RequireApproval } from '@aws-cdk/cloud-assembly-schema'; -import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, ToolkitAction, ToolkitOptions, UnstableFeature } from '@aws-cdk/toolkit-lib'; +import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib'; import { PermissionChangeType, Toolkit, ToolkitError } from '@aws-cdk/toolkit-lib'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; @@ -211,6 +211,7 @@ export class CdkToolkit { 'flags': true, 'orphan': true, 'refactor': true, + 'validate': true, }; this.toolkit = new InternalToolkit(props.sdkProvider, { @@ -844,6 +845,14 @@ export class CdkToolkit { return totalDrifts > 0 && options.fail ? 1 : 0; } + /** + * Validate synthesized templates against policy rules + */ + public async validate(options: ValidateOptions): Promise { + const result = await this.toolkit.validate(this.props.cloudExecutable, options); + return result.conclusion === 'failure' ? 1 : 0; + } + /** * Diagnose errors */ diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index ffb8f0119..8e04bba8d 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -222,6 +222,14 @@ export async function makeConfig(): Promise { variadic: true, }, }, + 'validate': { + description: 'Validate synthesized CloudFormation templates against policy rules', + options: {}, + arg: { + name: 'STACKS', + variadic: true, + }, + }, 'diagnose': { description: 'Find the root cause(s) of stack deployment failures', options: { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 97aeea0b5..33b467b4d 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -590,6 +590,14 @@ "variadic": true } }, + "validate": { + "description": "Validate synthesized CloudFormation templates against policy rules", + "options": {}, + "arg": { + "name": "STACKS", + "variadic": true + } + }, "diagnose": { "description": "Find the root cause(s) of stack deployment failures", "options": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index f406498e0..2a3f196ed 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -444,6 +444,14 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { desc: 'Create a drift-aware change set that brings actual resource states in line with template definitions', }), ) + .command('validate [STACKS..]', 'Validate synthesized CloudFormation templates against policy rules', (yargs: Argv) => yargs) .command('diagnose [STACKS..]', 'Find the root cause(s) of stack deployment failures', (yargs: Argv) => yargs .option('toolkit-stack-name', { diff --git a/packages/aws-cdk/lib/cli/user-configuration.ts b/packages/aws-cdk/lib/cli/user-configuration.ts index 055d8bc0a..61438a348 100644 --- a/packages/aws-cdk/lib/cli/user-configuration.ts +++ b/packages/aws-cdk/lib/cli/user-configuration.ts @@ -43,6 +43,7 @@ export enum Command { DRIFT = 'drift', CLI_TELEMETRY = 'cli-telemetry', DIAGNOSE = 'diagnose', + VALIDATE = 'validate', } const BUNDLING_COMMANDS = [ diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 08d103b42..3d1d7b981 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -55,6 +55,11 @@ export interface UserInput { */ readonly deploy?: DeployOptions; + /** + * Validate synthesized CloudFormation templates against policy rules + */ + readonly validate?: ValidateOptions; + /** * Find the root cause(s) of stack deployment failures */ @@ -981,6 +986,18 @@ export interface DeployOptions { readonly STACKS?: Array; } +/** + * Validate synthesized CloudFormation templates against policy rules + * + * @struct + */ +export interface ValidateOptions { + /** + * Positional argument for validate + */ + readonly STACKS?: Array; +} + /** * Find the root cause(s) of stack deployment failures * From 1e63544f770f3d77d77104773be56d7d6c3b3b92 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Wed, 20 May 2026 20:57:40 -0400 Subject: [PATCH 2/8] fix(toolkit): emit error-level IO message on validation failure hostMessageFromValidation was incorrectly always emitting at info level (CDK_TOOLKIT_I9600). Now uses CDK_TOOLKIT_E9600 (error level) when conclusion is 'failure', preserving the IO contract for consumers that rely on message level to detect problems. --- .../lib/api/validate/validate-formatting.ts | 21 +++++++------------ .../toolkit-lib/test/actions/validate.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts index 5429acfb3..f876326d4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts @@ -20,6 +20,9 @@ const SEVERITY_ORDER: Record = { }; export function hostMessageFromValidation(result: ValidateResult): ActionLessMessage { + if (result.conclusion === 'failure') { + return IO.CDK_TOOLKIT_E9600.msg(formatValidateResult(result), result); + } return IO.CDK_TOOLKIT_I9600.msg(formatValidateResult(result), result); } @@ -77,7 +80,7 @@ function formatViolationBlock(v: FlattenedViolation): string { } const severityColor = getSeverityColor(v.severity); - const severityAndDesc = severityColor(chalk.bold(`${formatSeverityName(v.severity)} ${v.description}`)); + const severityAndDesc = severityColor(chalk.bold(`${v.severity} ${v.description}`)); lines.push(`${severityAndDesc} ${v.pluginName}`); const constructInfo = formatConstructInfo(v.construct); @@ -91,22 +94,12 @@ function formatViolationBlock(v: FlattenedViolation): string { return lines.join('\n'); } -function formatSeverityName(severity: string): string { - switch (severity.toLowerCase()) { - case 'fatal': return 'Fatal'; - case 'error': return 'Error'; - case 'warning': return 'Warning'; - case 'info': return 'Info'; - default: return severity; - } -} - function getSeverityColor(severity: string): (str: string) => string { switch (severity.toLowerCase()) { case 'fatal': return chalk.red; case 'error': return chalk.hex('#FFA500'); case 'warning': return chalk.yellow; - case 'info': return chalk.yellow; + case 'info': return chalk.blue; default: return chalk.yellow; } } @@ -133,7 +126,7 @@ function getLeafLocation(stackTraces: string[] | undefined): string | undefined const lastTrace = stackTraces[stackTraces.length - 1]; const frames = lastTrace.split('\n'); if (frames.length === 0) return undefined; - const leafFrame = frames[0]; - const match = leafFrame.match(/\((.+)\)$/); + const leafFrame = frames[0].trim(); + const match = leafFrame.match(/\((.+)\)$/) || leafFrame.match(/at\s+(.+)$/); return match ? match[1] : leafFrame; } diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts index 2303a15b8..2fbcb1d98 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -104,12 +104,12 @@ describe('validate', () => { expect(construct.stackTraces![0]).toContain('new MyStack (lib/my-stack.ts:30:5)'); }); - test('IO message payload contains full ValidateResult', async () => { + test('IO message payload contains full ValidateResult at error level on failure', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); await toolkit.validate(cx); const msg = ioHost.messages.find( - (m) => m.code === 'CDK_TOOLKIT_I9600', + (m) => m.code === 'CDK_TOOLKIT_E9600', ); expect(msg).toBeDefined(); expect(msg!.data).toMatchObject({ From 718efbcb38831a32a12210acf01dfa6ae72af8d0 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Wed, 20 May 2026 21:02:06 -0400 Subject: [PATCH 3/8] test(toolkit): add unit tests for validate-formatting Covers severity sorting, construct path formatting, stack trace parsing (both paren and bare formats), acknowledge line omission for fatal, and constructFqn display. --- .../api/validate/validate-formatting.test.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts diff --git a/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts new file mode 100644 index 000000000..2a3dfb94e --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts @@ -0,0 +1,170 @@ +import * as chalk from 'chalk'; +import { formatValidateResult } from '../../../lib/api/validate/validate-formatting'; +import type { ValidateResult } from '../../../lib/actions/validate'; + +// Disable chalk for predictable assertions +chalk.level = 0; + +function makeResult(pluginReports: ValidateResult['pluginReports']): ValidateResult { + const conclusion = pluginReports.some((r) => r.conclusion === 'failure') ? 'failure' : 'success'; + return { conclusion, pluginReports } as ValidateResult; +} + +describe('formatValidateResult', () => { + test('returns pass message when no violations', () => { + const result = makeResult([ + { pluginName: 'TestPlugin', conclusion: 'success', violations: [] }, + ]); + expect(formatValidateResult(result)).toBe('Policy validation passed. No violations found.'); + }); + + test('sorts violations by severity (fatal > error > warning > info > custom)', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [ + { ruleName: 'r1', description: 'info issue', severity: 'info', violatingConstructs: [{ constructPath: 'Stack/A' }] }, + { ruleName: 'r2', description: 'fatal issue', severity: 'fatal', violatingConstructs: [{ constructPath: 'Stack/B' }] }, + { ruleName: 'r3', description: 'warning issue', severity: 'warning', violatingConstructs: [{ constructPath: 'Stack/C' }] }, + { ruleName: 'r4', description: 'error issue', severity: 'error', violatingConstructs: [{ constructPath: 'Stack/D' }] }, + { ruleName: 'r5', description: 'custom issue', severity: 'custom', violatingConstructs: [{ constructPath: 'Stack/E' }] }, + ], + }]); + + const output = formatValidateResult(result); + const lines = output.split('\n\n'); + expect(lines[0]).toContain('fatal issue'); + expect(lines[1]).toContain('error issue'); + expect(lines[2]).toContain('warning issue'); + expect(lines[3]).toContain('info issue'); + expect(lines[4]).toContain('custom issue'); + }); + + test('formats construct path with logical id', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad thing', + severity: 'error', + violatingConstructs: [{ + constructPath: 'Stack/MyBucket/Resource', + cloudFormationResource: { templatePath: 'Stack.template.json', logicalId: 'MyBucketF68F3FF0' }, + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('Stack/MyBucket/Resource'); + expect(output).toContain('MyBucketF68F3FF0'); + }); + + test('formats construct with only logical id when no construct path', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad thing', + severity: 'error', + violatingConstructs: [{ + constructPath: '', + cloudFormationResource: { templatePath: 'Stack.template.json', logicalId: 'MyResource' }, + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('MyResource'); + }); + + test('extracts leaf location from stack trace with parens', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad', + severity: 'error', + violatingConstructs: [{ + constructPath: 'Stack/Bucket', + stackTraces: ['new Bucket (lib/my-stack.ts:12:5)\nnew MyStack (lib/my-stack.ts:30:5)'], + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('lib/my-stack.ts:12:5'); + }); + + test('extracts leaf location from bare stack trace without parens', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad', + severity: 'error', + violatingConstructs: [{ + constructPath: 'Stack/Bucket', + stackTraces: ['at file.js:10:5'], + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('file.js:10:5'); + }); + + test('omits acknowledge line for fatal severity', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'critical', + severity: 'fatal', + violatingConstructs: [{ constructPath: 'Stack/Resource' }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).not.toContain('Acknowledge'); + }); + + test('includes acknowledge line for non-fatal severities', () => { + const result = makeResult([{ + pluginName: 'SecurityPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'no-public-buckets', + description: 'bad bucket', + severity: 'error', + violatingConstructs: [{ constructPath: 'Stack/Bucket' }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain("Acknowledge 'SecurityPlugin::no-public-buckets'"); + }); + + test('includes constructFqn when present', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad', + severity: 'warning', + violatingConstructs: [{ + constructPath: 'Stack/Bucket', + constructFqn: 'aws-cdk-lib/aws-s3.Bucket', + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('aws-cdk-lib/aws-s3.Bucket'); + }); +}); From 9c7120ecde78a467ba70502f224d59f1439480aa Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Thu, 21 May 2026 11:18:36 -0400 Subject: [PATCH 4/8] fix(cli): use correct context key for suppressing synth validation errors Update to @aws-cdk/core:failSynthOnValidationErrors=false to match the aws-cdk PR (aws/aws-cdk#37909) which introduces this key. The old @aws-cdk/core:validationReportOnly was a placeholder. --- .../resources/cdk-apps/validate-app/app.js | 35 +++---------------- .../resources/cdk-apps/validate-app/cdk.json | 2 +- ...cknowledge-suppresses-warning.integtest.ts | 28 --------------- ...k-validate-reports-violations.integtest.ts | 7 +--- packages/aws-cdk/lib/cli/cli.ts | 2 +- 5 files changed, 7 insertions(+), 67 deletions(-) delete mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-acknowledge-suppresses-warning.integtest.ts diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js index 01a28f12a..68479b43b 100644 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js @@ -48,26 +48,6 @@ class SecurityPlugin { locations: ['/Resources/MyBucket/Properties/VersioningConfiguration'], })), }, - { - ruleName: 'consider-intelligent-tiering', - description: 'Consider using Intelligent-Tiering storage class for cost optimization', - severity: 'info', - violatingResources: context.templatePaths.map(templatePath => ({ - resourceLogicalId: 'MyBucket', - templatePath, - locations: ['/Resources/MyBucket/Properties/IntelligentTieringConfigurations'], - })), - }, - { - ruleName: 'org-tagging-policy', - description: 'Resource does not comply with organization tagging policy TG-0042', - severity: 'compliance', - violatingResources: context.templatePaths.map(templatePath => ({ - resourceLogicalId: 'MyBucket', - templatePath, - locations: ['/Resources/MyBucket/Properties/Tags'], - })), - }, ], }; } @@ -88,24 +68,17 @@ class AlwaysPassesPlugin { } const shouldFail = process.env.VALIDATION_SHOULD_FAIL === 'true'; -const shouldAcknowledge = process.env.VALIDATION_ACKNOWLEDGE === 'true'; -const app = new cdk.App(); -cdk.Validations.of(app).addPlugins(shouldFail ? new SecurityPlugin() : new AlwaysPassesPlugin()); +const app = new cdk.App({ + policyValidationBeta1: [shouldFail ? new SecurityPlugin() : new AlwaysPassesPlugin()], +}); class ValidateStack extends cdk.Stack { constructor(scope, id, props) { super(scope, id, props); - const bucket = new s3.Bucket(this, 'MyBucket', { + new s3.Bucket(this, 'MyBucket', { removalPolicy: cdk.RemovalPolicy.DESTROY, }); - - // Construct Annotations plugin will pick up this warning - cdk.Validations.of(bucket).addWarning('bucket-no-lifecycle', 'This bucket has no lifecycle rules configured'); - - if (shouldAcknowledge) { - cdk.Validations.of(bucket).acknowledge({ id: 'bucket-no-lifecycle', reason: 'Lifecycle rules not needed for this use case' }); - } } } diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json index 0947e711e..791696b03 100644 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json @@ -2,6 +2,6 @@ "app": "node app.js", "versionReporting": false, "context": { - "@aws-cdk/core:validationReportOnly": true + "@aws-cdk/core:failSynthOnValidationErrors": false } } diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-acknowledge-suppresses-warning.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-acknowledge-suppresses-warning.integtest.ts deleted file mode 100644 index 72474cb0a..000000000 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-acknowledge-suppresses-warning.integtest.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { integTest, withSpecificFixture } from '../../../lib'; - -integTest( - 'cdk validate acknowledge suppresses warning', - withSpecificFixture('validate-app', async (fixture) => { - // Without acknowledgment, the annotation warning should appear - const withWarning = await fixture.cdk( - ['--unstable=validate', 'validate', fixture.fullStackName('validate')], - { - modEnv: { VALIDATION_SHOULD_FAIL: 'false', VALIDATION_ACKNOWLEDGE: 'false' }, - allowErrExit: true, - }, - ); - - expect(withWarning).toContain('This bucket has no lifecycle rules configured'); - - // With acknowledgment, the annotation warning should be suppressed - const acknowledged = await fixture.cdk( - ['--unstable=validate', 'validate', fixture.fullStackName('validate')], - { - modEnv: { VALIDATION_SHOULD_FAIL: 'false', VALIDATION_ACKNOWLEDGE: 'true' }, - }, - ); - - expect(acknowledged).not.toContain('This bucket has no lifecycle rules configured'); - expect(acknowledged).toContain('Policy validation passed. No violations found.'); - }), -); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts index b21c0d2a6..4cc65282c 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts @@ -6,19 +6,14 @@ integTest( const output = await fixture.cdk( ['--unstable=validate', 'validate', fixture.fullStackName('validate')], { - modEnv: { VALIDATION_SHOULD_FAIL: 'true', VALIDATION_ACKNOWLEDGE: 'false' }, + modEnv: { VALIDATION_SHOULD_FAIL: 'true' }, allowErrExit: true, }, ); - // SecurityPlugin violations expect(output).toContain('S3 Buckets must not be publicly accessible'); expect(output).toContain('S3 Buckets must have server-side encryption enabled'); expect(output).toContain('S3 Buckets should have versioning enabled for data protection'); expect(output).toContain('SecurityPlugin'); - - // Construct Annotations plugin picks up the addWarning - expect(output).toContain('This bucket has no lifecycle rules configured'); - expect(output).toContain('Construct Annotations'); }), ); diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 2a3f196ed..446e19782 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -447,7 +447,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise Date: Thu, 21 May 2026 17:41:22 -0400 Subject: [PATCH 5/8] fix(toolkit): handle aws-cdk-lib report format and info-level IO output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use fs.readJson instead of Manifest.loadValidationReport (aws-cdk-lib doesn't write a version field yet, so schema validation throws) - Emit validate results at info level so the CLI IoHost doesn't wrap the entire output in red — the formatter handles per-severity coloring - Update fixture app to use cdk.Validations.of(app).addPlugins() - Use @aws-cdk/core:failSynthOnValidationErrors context key (2.257.0) --- .../resources/cdk-apps/validate-app/app.js | 5 ++--- .../lib/api/validate/validate-formatting.ts | 11 +++++----- .../toolkit-lib/test/actions/validate.test.ts | 4 ++-- yarn.lock | 22 +++++++++---------- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js index 68479b43b..e4c452174 100644 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js @@ -69,9 +69,8 @@ class AlwaysPassesPlugin { const shouldFail = process.env.VALIDATION_SHOULD_FAIL === 'true'; -const app = new cdk.App({ - policyValidationBeta1: [shouldFail ? new SecurityPlugin() : new AlwaysPassesPlugin()], -}); +const app = new cdk.App(); +cdk.Validations.of(app).addPlugins(shouldFail ? new SecurityPlugin() : new AlwaysPassesPlugin()); class ValidateStack extends cdk.Stack { constructor(scope, id, props) { diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts index f876326d4..be0210b2b 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts @@ -20,9 +20,9 @@ const SEVERITY_ORDER: Record = { }; export function hostMessageFromValidation(result: ValidateResult): ActionLessMessage { - if (result.conclusion === 'failure') { - return IO.CDK_TOOLKIT_E9600.msg(formatValidateResult(result), result); - } + // Always emit at info level so the CLI IoHost doesn't wrap the entire output + // in a single color. The formatter handles per-severity coloring internally. + // Consumers detect failure via the structured `data.conclusion` field or exit code. return IO.CDK_TOOLKIT_I9600.msg(formatValidateResult(result), result); } @@ -97,10 +97,9 @@ function formatViolationBlock(v: FlattenedViolation): string { function getSeverityColor(severity: string): (str: string) => string { switch (severity.toLowerCase()) { case 'fatal': return chalk.red; - case 'error': return chalk.hex('#FFA500'); + case 'error': return chalk.ansi256(208); case 'warning': return chalk.yellow; - case 'info': return chalk.blue; - default: return chalk.yellow; + default: return chalk.blue; } } diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts index 2fbcb1d98..2303a15b8 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -104,12 +104,12 @@ describe('validate', () => { expect(construct.stackTraces![0]).toContain('new MyStack (lib/my-stack.ts:30:5)'); }); - test('IO message payload contains full ValidateResult at error level on failure', async () => { + test('IO message payload contains full ValidateResult', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); await toolkit.validate(cx); const msg = ioHost.messages.find( - (m) => m.code === 'CDK_TOOLKIT_E9600', + (m) => m.code === 'CDK_TOOLKIT_I9600', ); expect(msg).toBeDefined(); expect(msg!.data).toMatchObject({ diff --git a/yarn.lock b/yarn.lock index b2fe8fca1..606e75dd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -235,14 +235,14 @@ __metadata: linkType: hard "@aws-cdk/cloud-assembly-api@npm:^2.2.3": - version: 2.2.3 - resolution: "@aws-cdk/cloud-assembly-api@npm:2.2.3" + version: 2.2.4 + resolution: "@aws-cdk/cloud-assembly-api@npm:2.2.4" dependencies: - jsonschema: "npm:~1.4.1" - semver: "npm:^7.7.4" + jsonschema: "npm:^1.5.0" + semver: "npm:^7.8.0" peerDependencies: - "@aws-cdk/cloud-assembly-schema": ">=53.21.0" - checksum: 10c0/3f9309a28ef6e7ba62ed83925d8471b3141e991ec895f7b0340a5baa6a57cab1408e6876203cea2f8e6db75cf9785248e4015b4dca8c941b0c0ea2fc7900b8ea + "@aws-cdk/cloud-assembly-schema": ">=53.25.0" + checksum: 10c0/49ff690c827a1f4795267a9f449d10b2098522f9c55a09d5dc42db93faf9091e7ddef81c37763e2300e4c17d0c0564586fcfc3dad02232fdcdc912d050db7c67 languageName: node linkType: hard @@ -299,12 +299,12 @@ __metadata: linkType: soft "@aws-cdk/cloud-assembly-schema@npm:^53.21.0": - version: 53.23.0 - resolution: "@aws-cdk/cloud-assembly-schema@npm:53.23.0" + version: 53.27.0 + resolution: "@aws-cdk/cloud-assembly-schema@npm:53.27.0" dependencies: - jsonschema: "npm:~1.4.1" - semver: "npm:^7.7.4" - checksum: 10c0/1fae7ecb9c1a4ee8030d1a7314aae95d010e295b9dc9e6c74d65794c4ca43b8b85899d5509af02fbf34abeaaae46d02542ba00612f6f319e83abea44fcb2eb90 + jsonschema: "npm:^1.5.0" + semver: "npm:^7.8.0" + checksum: 10c0/862ac533f5a261e24386fd6eff9cd18dca2be8b2799f2b8d3c3d6df57b3eba01bf78bdb265166466d5fafc6cd2d7d065f1cb0fe175c5b84ef41064fd00ae304e languageName: node linkType: hard From 9eb043705e69ee5e61f28d02f609422323ddf4a6 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Thu, 21 May 2026 19:26:01 -0400 Subject: [PATCH 6/8] fix(toolkit): rename report to validation-report.json, fix formatter, update integ tests - Look for validation-report.json only (new format from aws-cdk-lib) - Strip [ack: ...] tags from descriptions, use dashes in acknowledge IDs - Underline file locations, add colon after severity, capitalize custom - Use relative paths for stack trace locations - Separate integ test apps: validate-app (failing) and validate-passing-app - Remove acknowledge integ test (depends on unreleased APIs) - Enable @aws-cdk/core:annotationsInValidationReport for Construct Annotations --- .../resources/cdk-apps/validate-app/app.js | 112 ++++++++++-------- .../resources/cdk-apps/validate-app/cdk.json | 3 +- .../cdk-apps/validate-passing-app/app.js | 36 ++++++ .../cdk-apps/validate-passing-app/cdk.json | 8 ++ ...cdk-validate-passes-clean-app.integtest.ts | 7 +- ...k-validate-reports-violations.integtest.ts | 6 +- .../lib/api/validate/validate-formatting.ts | 17 ++- .../toolkit-lib/lib/toolkit/toolkit.ts | 4 +- ...ion-report.json => validation-report.json} | 0 9 files changed, 127 insertions(+), 66 deletions(-) create mode 100644 packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/app.js create mode 100644 packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/cdk.json rename packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/{policy-validation-report.json => validation-report.json} (100%) diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js index e4c452174..32c4bb235 100644 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js @@ -1,3 +1,4 @@ +const fs = require('fs'); const cdk = require('aws-cdk-lib/core'); const s3 = require('aws-cdk-lib/aws-s3'); @@ -13,71 +14,78 @@ class SecurityPlugin { } validate(context) { - return { - success: false, - violations: [ - { - ruleName: 'no-public-buckets', - description: 'S3 Buckets must not be publicly accessible', - fix: 'Set PublicAccessBlockConfiguration on the bucket', - severity: 'fatal', - violatingResources: context.templatePaths.map(templatePath => ({ - resourceLogicalId: 'MyBucket', - templatePath, - locations: ['/Resources/MyBucket/Properties/PublicAccessBlockConfiguration'], - })), - }, - { - ruleName: 'require-encryption', - description: 'S3 Buckets must have server-side encryption enabled', - fix: 'Add BucketEncryption property with SSE-S3 or SSE-KMS', - severity: 'error', - violatingResources: context.templatePaths.map(templatePath => ({ - resourceLogicalId: 'MyBucket', - templatePath, - locations: ['/Resources/MyBucket/Properties/BucketEncryption'], - })), - }, - { - ruleName: 'require-versioning', - description: 'S3 Buckets should have versioning enabled for data protection', - severity: 'warning', - violatingResources: context.templatePaths.map(templatePath => ({ - resourceLogicalId: 'MyBucket', - templatePath, - locations: ['/Resources/MyBucket/Properties/VersioningConfiguration'], - })), - }, - ], - }; - } -} + const violations = []; + for (const templatePath of context.templatePaths) { + const template = JSON.parse(fs.readFileSync(templatePath, 'utf-8')); + for (const [logicalId, resource] of Object.entries(template.Resources || {})) { + if (resource.Type === 'AWS::S3::Bucket') { + violations.push( + { + ruleName: 'no-public-buckets', + description: 'S3 Buckets must not be publicly accessible', + fix: 'Set PublicAccessBlockConfiguration on the bucket', + severity: 'fatal', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/PublicAccessBlockConfiguration`], + }], + }, + { + ruleName: 'require-encryption', + description: 'S3 Buckets must have server-side encryption enabled', + fix: 'Add BucketEncryption property with SSE-S3 or SSE-KMS', + severity: 'error', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/BucketEncryption`], + }], + }, + { + ruleName: 'require-versioning', + description: 'S3 Buckets should have versioning enabled for data protection', + severity: 'warning', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/VersioningConfiguration`], + }], + }, + { + ruleName: 'consider-intelligent-tiering', + description: 'Consider using Intelligent-Tiering storage class for cost optimization', + severity: 'cost-optimization', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/IntelligentTieringConfigurations`], + }], + }, + ); + } + } + } -class AlwaysPassesPlugin { - constructor() { - this.name = 'AlwaysPassesPlugin'; - this.version = '1.0.0'; - } - - validate(_context) { return { - success: true, - violations: [], + success: violations.length === 0, + violations, }; } } -const shouldFail = process.env.VALIDATION_SHOULD_FAIL === 'true'; - const app = new cdk.App(); -cdk.Validations.of(app).addPlugins(shouldFail ? new SecurityPlugin() : new AlwaysPassesPlugin()); +cdk.Validations.of(app).addPlugins(new SecurityPlugin()); class ValidateStack extends cdk.Stack { constructor(scope, id, props) { super(scope, id, props); - new s3.Bucket(this, 'MyBucket', { + const bucket = new s3.Bucket(this, 'MyBucket', { removalPolicy: cdk.RemovalPolicy.DESTROY, }); + + // Construct Annotations plugin picks this up + cdk.Annotations.of(bucket).addWarningV2('bucket-no-lifecycle', 'This bucket has no lifecycle rules configured'); } } diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json index 791696b03..62ede1daa 100644 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json @@ -2,6 +2,7 @@ "app": "node app.js", "versionReporting": false, "context": { - "@aws-cdk/core:failSynthOnValidationErrors": false + "@aws-cdk/core:failSynthOnValidationErrors": false, + "@aws-cdk/core:annotationsInValidationReport": true } } diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/app.js new file mode 100644 index 000000000..88da7f022 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/app.js @@ -0,0 +1,36 @@ +const cdk = require('aws-cdk-lib/core'); + +const stackPrefix = process.env.STACK_NAME_PREFIX; +if (!stackPrefix) { + throw new Error('the STACK_NAME_PREFIX environment variable is required'); +} + +class AlwaysPassesPlugin { + constructor() { + this.name = 'AlwaysPassesPlugin'; + this.version = '1.0.0'; + } + + validate(_context) { + return { + success: true, + violations: [], + }; + } +} + +const app = new cdk.App(); +cdk.Validations.of(app).addPlugins(new AlwaysPassesPlugin()); + +class PassingStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + new cdk.CfnResource(this, 'WaitHandle', { + type: 'AWS::CloudFormation::WaitConditionHandle', + }); + } +} + +new PassingStack(app, `${stackPrefix}-validate-passing`); + +app.synth(); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/cdk.json new file mode 100644 index 000000000..62ede1daa --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/cdk.json @@ -0,0 +1,8 @@ +{ + "app": "node app.js", + "versionReporting": false, + "context": { + "@aws-cdk/core:failSynthOnValidationErrors": false, + "@aws-cdk/core:annotationsInValidationReport": true + } +} diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts index 480c4633d..294ed1918 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts @@ -2,12 +2,9 @@ import { integTest, withSpecificFixture } from '../../../lib'; integTest( 'cdk validate passes for clean app', - withSpecificFixture('validate-app', async (fixture) => { + withSpecificFixture('validate-passing-app', async (fixture) => { const output = await fixture.cdk( - ['--unstable=validate', 'validate', fixture.fullStackName('validate')], - { - modEnv: { VALIDATION_SHOULD_FAIL: 'false' }, - }, + ['--unstable=validate', 'validate', fixture.fullStackName('validate-passing')], ); expect(output).toContain('Policy validation passed. No violations found.'); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts index 4cc65282c..373fb9be0 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts @@ -6,14 +6,18 @@ integTest( const output = await fixture.cdk( ['--unstable=validate', 'validate', fixture.fullStackName('validate')], { - modEnv: { VALIDATION_SHOULD_FAIL: 'true' }, allowErrExit: true, }, ); + // SecurityPlugin violations expect(output).toContain('S3 Buckets must not be publicly accessible'); expect(output).toContain('S3 Buckets must have server-side encryption enabled'); expect(output).toContain('S3 Buckets should have versioning enabled for data protection'); expect(output).toContain('SecurityPlugin'); + + // Construct Annotations plugin picks up the addWarningV2 + expect(output).toContain('This bucket has no lifecycle rules configured'); + expect(output).toContain('Construct Annotations'); }), ); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts index be0210b2b..f6b8397c0 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts @@ -1,3 +1,4 @@ +import * as path from 'node:path'; import * as chalk from 'chalk'; import type { PluginReportJson, ViolatingConstructJson } from '@aws-cdk/cloud-assembly-schema'; import type { ValidateResult } from '../../actions/validate'; @@ -68,7 +69,7 @@ function normalizeSeverity(severity: string | undefined): string { if (lower === 'error') return 'Error'; if (lower === 'warning') return 'Warning'; if (lower === 'info') return 'Info'; - return severity; + return severity.charAt(0).toUpperCase() + severity.slice(1); } function formatViolationBlock(v: FlattenedViolation): string { @@ -76,18 +77,19 @@ function formatViolationBlock(v: FlattenedViolation): string { const location = getLeafLocation(v.construct.stackTraces); if (location) { - lines.push(location); + lines.push(chalk.underline(location)); } const severityColor = getSeverityColor(v.severity); - const severityAndDesc = severityColor(chalk.bold(`${v.severity} ${v.description}`)); + const description = stripAckTag(v.description); + const severityAndDesc = severityColor(chalk.bold(`${v.severity}: ${description}`)); lines.push(`${severityAndDesc} ${v.pluginName}`); const constructInfo = formatConstructInfo(v.construct); lines.push(` ${constructInfo}`); if (v.severity.toLowerCase() !== 'fatal') { - const ackId = `${v.pluginName}::${v.ruleName}`; + const ackId = `${v.pluginName}::${v.ruleName}`.replace(/ /g, '-'); lines.push(` Acknowledge '${ackId}'`); } @@ -120,6 +122,10 @@ function formatConstructInfo(construct: ViolatingConstructJson): string { return parts.join(' '); } +function stripAckTag(description: string): string { + return description.replace(/\s*\[ack:\s*[^\]]+\]\s*/g, '').trim(); +} + function getLeafLocation(stackTraces: string[] | undefined): string | undefined { if (!stackTraces || stackTraces.length === 0) return undefined; const lastTrace = stackTraces[stackTraces.length - 1]; @@ -127,5 +133,6 @@ function getLeafLocation(stackTraces: string[] | undefined): string | undefined if (frames.length === 0) return undefined; const leafFrame = frames[0].trim(); const match = leafFrame.match(/\((.+)\)$/) || leafFrame.match(/at\s+(.+)$/); - return match ? match[1] : leafFrame; + const location = match ? match[1] : leafFrame; + return path.isAbsolute(location.split(':')[0]) ? path.relative(process.cwd(), location) : location; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 239c9f44f..0ee0e6441 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -115,7 +115,7 @@ import { pLimit } from '../util/concurrency'; import { createIgnoreMatcher } from '../util/glob-matcher'; import { promiseWithResolvers } from '../util/promises'; -const POLICY_VALIDATION_REPORT_FILE = 'validation-report.json'; +const VALIDATION_REPORT_FILE = 'validation-report.json'; export interface ToolkitOptions { /** @@ -664,7 +664,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { const selectStacks = stacksOpt(options); await using assembly = await synthAndMeasure(ioHelper, cx, selectStacks); - const reportPath = path.join(assembly.directory, POLICY_VALIDATION_REPORT_FILE); + const reportPath = path.join(assembly.directory, VALIDATION_REPORT_FILE); if (!await fs.pathExists(reportPath)) { const result: ValidateResult = { diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/validation-report.json similarity index 100% rename from packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/policy-validation-report.json rename to packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/validation-report.json From 175e341804b6af6d1c2b67bb5e01ced000f25f08 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Fri, 22 May 2026 10:15:30 -0400 Subject: [PATCH 7/8] fix(toolkit): use 'problems' instead of 'violations' in success message --- .../toolkit-lib/lib/api/validate/validate-formatting.ts | 2 +- packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts | 2 +- .../test/api/validate/validate-formatting.test.ts | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts index f6b8397c0..ccc1304f2 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts @@ -31,7 +31,7 @@ export function formatValidateResult(result: ValidateResult): string { const violations = flattenViolations(result.pluginReports); if (violations.length === 0) { - return 'Policy validation passed. No violations found.'; + return 'Policy validation passed. No problems found.'; } violations.sort((a, b) => { diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts index 2303a15b8..86a8791cd 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -48,7 +48,7 @@ describe('validate', () => { const cx = await cdkOutFixture(toolkit, 'stack-with-passing-validation'); await toolkit.validate(cx); - ioHost.expectMessage({ containing: 'No violations found', level: 'info' }); + ioHost.expectMessage({ containing: 'No problems found', level: 'info' }); }); test('can invoke without options', async () => { diff --git a/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts index 2a3dfb94e..e16dc3668 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts @@ -1,9 +1,8 @@ -import * as chalk from 'chalk'; import { formatValidateResult } from '../../../lib/api/validate/validate-formatting'; import type { ValidateResult } from '../../../lib/actions/validate'; // Disable chalk for predictable assertions -chalk.level = 0; +process.env.FORCE_COLOR = '0'; function makeResult(pluginReports: ValidateResult['pluginReports']): ValidateResult { const conclusion = pluginReports.some((r) => r.conclusion === 'failure') ? 'failure' : 'success'; @@ -15,7 +14,7 @@ describe('formatValidateResult', () => { const result = makeResult([ { pluginName: 'TestPlugin', conclusion: 'success', violations: [] }, ]); - expect(formatValidateResult(result)).toBe('Policy validation passed. No violations found.'); + expect(formatValidateResult(result)).toBe('Policy validation passed. No problems found.'); }); test('sorts violations by severity (fatal > error > warning > info > custom)', () => { From f94f3a4ababe2e79034e73936875d0715036364a Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Fri, 22 May 2026 11:04:18 -0400 Subject: [PATCH 8/8] fix(toolkit): add title and spacing to validation report output --- .../lib/api/validate/validate-formatting.ts | 5 +++-- .../test/api/validate/validate-formatting.test.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts index ccc1304f2..f63308a88 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts @@ -31,7 +31,7 @@ export function formatValidateResult(result: ValidateResult): string { const violations = flattenViolations(result.pluginReports); if (violations.length === 0) { - return 'Policy validation passed. No problems found.'; + return '\nPolicy validation passed. No problems found.'; } violations.sort((a, b) => { @@ -40,8 +40,9 @@ export function formatValidateResult(result: ValidateResult): string { return aOrder - bOrder; }); + const title = result.title ?? 'Validation Report'; const blocks = violations.map((v) => formatViolationBlock(v)); - return blocks.join('\n\n'); + return `\n${title}\n${'-'.repeat(title.length)}\n\n${blocks.join('\n\n')}`; } function flattenViolations(pluginReports: PluginReportJson[]): FlattenedViolation[] { diff --git a/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts index e16dc3668..cf403fb7a 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts @@ -14,7 +14,7 @@ describe('formatValidateResult', () => { const result = makeResult([ { pluginName: 'TestPlugin', conclusion: 'success', violations: [] }, ]); - expect(formatValidateResult(result)).toBe('Policy validation passed. No problems found.'); + expect(formatValidateResult(result)).toContain('No problems found.'); }); test('sorts violations by severity (fatal > error > warning > info > custom)', () => { @@ -31,12 +31,12 @@ describe('formatValidateResult', () => { }]); const output = formatValidateResult(result); - const lines = output.split('\n\n'); - expect(lines[0]).toContain('fatal issue'); - expect(lines[1]).toContain('error issue'); - expect(lines[2]).toContain('warning issue'); - expect(lines[3]).toContain('info issue'); - expect(lines[4]).toContain('custom issue'); + const lines = output.split('\n\n').filter(l => l.trim()); + expect(lines[1]).toContain('fatal issue'); + expect(lines[2]).toContain('error issue'); + expect(lines[3]).toContain('warning issue'); + expect(lines[4]).toContain('info issue'); + expect(lines[5]).toContain('custom issue'); }); test('formats construct path with logical id', () => {