From c6afb93f53ac85e22fe8a7515ad4d4361515c64b Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Wed, 20 May 2026 18:31:22 -0400 Subject: [PATCH 01/11] 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 2be20f742..448f9e5e9 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 9c39a5083..7eaf972c4 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -221,6 +221,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 fbab3f012..5d8fdd9cd 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -585,6 +585,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 173b2e193..d4c9d2d41 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -443,6 +443,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 afb41f997..8a8aa33ea 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 */ @@ -974,6 +979,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 a6ef1be1a7db37a570071231b9cb881e5af27829 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Wed, 20 May 2026 20:57:40 -0400 Subject: [PATCH 02/11] 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 b58918e9212781a47c282f4e09b4a436a395e46b Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Wed, 20 May 2026 21:02:06 -0400 Subject: [PATCH 03/11] 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 96eee8f69f69519317c7aeea7989a0653a309396 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Thu, 21 May 2026 11:18:36 -0400 Subject: [PATCH 04/11] 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 d4c9d2d41..7987f5f4d 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -446,7 +446,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise Date: Thu, 21 May 2026 17:41:22 -0400 Subject: [PATCH 05/11] 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 fe8ef21d4..4c61e9530 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 @@ -287,12 +287,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 39eb481ddfc8bc49e68719e2ba1dd87bf5f9fe26 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Thu, 21 May 2026 19:26:01 -0400 Subject: [PATCH 06/11] 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 ...ion-report.json => validation-report.json} | 0 ...ion-report.json => validation-report.json} | 0 ...ion-report.json => validation-report.json} | 0 ...ion-report.json => validation-report.json} | 0 13 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-malformed-validation-report/cdk.out/{policy-validation-report.json => validation-report.json} (100%) rename packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/{policy-validation-report.json => validation-report.json} (100%) rename packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/{policy-validation-report.json => validation-report.json} (100%) rename packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/{policy-validation-report.json => validation-report.json} (100%) rename packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/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 448f9e5e9..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 = 'policy-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-malformed-validation-report/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/validation-report.json similarity index 100% rename from packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/policy-validation-report.json rename to packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/validation-report.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/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 diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/validation-report.json similarity index 100% rename from packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/policy-validation-report.json rename to packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/validation-report.json diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/validation-report.json similarity index 100% rename from packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/policy-validation-report.json rename to packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/validation-report.json diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/validation-report.json similarity index 100% rename from packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/policy-validation-report.json rename to packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/validation-report.json From 60ef903134d41c90dde966e33f2ce7e4150c37f8 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Wed, 20 May 2026 20:23:22 -0400 Subject: [PATCH 07/11] feat(toolkit): implement cdk validate --online via CloudFormation early validation Refactor waitForChangeSet into waitForChangeSetReport (returns diagnosis without throwing) + waitForChangeSet wrapper (throws, preserving existing behavior for deploy/diff). Add createValidationChangeSet() which creates a non-executing change set, collects early validation errors via the existing diagnoser infrastructure, and always cleans up afterwards. Extend validate() to optionally run online validation after reading the offline report, merging CloudFormation early validation errors into the combined result as a synthetic "CloudFormation" plugin report using proper schema types (PluginReportJson, ViolatingConstructJson). --- .../toolkit-lib/lib/actions/validate/index.ts | 11 ++ .../lib/api/deployments/cfn-api.ts | 124 +++++++++++++++--- .../toolkit-lib/lib/toolkit/toolkit.ts | 91 +++++++++++-- .../toolkit-lib/test/actions/validate.test.ts | 23 ++-- packages/aws-cdk/lib/cli/cli-config.ts | 4 +- packages/aws-cdk/lib/cli/cli.ts | 1 + 6 files changed, 209 insertions(+), 45 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts index 93f4b2272..ee4f3a09d 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts @@ -6,6 +6,17 @@ export interface ValidateOptions { * Select the stacks to validate */ readonly stacks?: StackSelector; + + /** + * Submit templates to CloudFormation for early validation. + * + * Creates a non-executing change set per stack and reports any + * early validation errors (invalid resource types, property validation, name conflicts). + * Requires AWS credentials. + * + * @default true + */ + readonly online?: boolean; } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts index 9a541db93..2664e4649 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts @@ -18,6 +18,7 @@ import { changeSetNameFromArn, stackNameFromArn } from '../../util/cloudformatio import type { ICloudFormationClient, SdkProvider } from '../aws-auth/private'; import type { Template, TemplateBodyParameter, TemplateParameter } from '../cloudformation'; import { CloudFormationStack, makeBodyParameter } from '../cloudformation'; +import type { StackDiagnosis } from '../../actions/diagnose'; import { throwDeploymentErrorFromDiagnosis } from '../diagnosing/diagnosis-formatting'; import { CloudFormationStackDiagnoser } from '../diagnosing/stack-diagnoser'; import type { TargetEnvironment } from '../environment'; @@ -89,26 +90,21 @@ async function waitFor( } } +export interface ChangeSetReport { + readonly description: DescribeChangeSetCommandOutput; + readonly diagnosis: StackDiagnosis; +} + /** - * Waits for a ChangeSet to be available for triggering a StackUpdate. - * - * Will return a changeset that is either ready to be executed or has no changes. - * Will throw in other cases. - * - * @param cfn - a CloudFormation client - * @param stackNameOrArn - the name or ARN of the Stack that the ChangeSet belongs to, prefer passing an ARN if available - * @param changeSetNameOrArn - the name or ARN of the ChangeSet, prefer passing an ARN if available - * @param fetchAll - if true, fetches all pages of the ChangeSet before returning. - * - * @returns the CloudFormation description of the ChangeSet + * Waits for a ChangeSet to reach a terminal state and returns the diagnosis without throwing. */ -export async function waitForChangeSet( +export async function waitForChangeSetReport( cfn: ICloudFormationClient, ioHelper: IoHelper, stackNameOrArn: string, changeSetNameOrArn: string, { fetchAll, diagnoser }: { fetchAll: boolean; diagnoser: CloudFormationStackDiagnoser }, -): Promise { +): Promise { const stackDisplayName = stackNameFromArn(stackNameOrArn); const changeSetDisplayName = changeSetNameFromArn(changeSetNameOrArn); await ioHelper.defaults.debug(format('Waiting for changeset %s on stack %s to finish creating...', changeSetDisplayName, stackDisplayName)); @@ -117,19 +113,13 @@ export async function waitForChangeSet( fetchAll, }); - // The following doesn't use a switch because tsc will not allow fall-through, UNLESS it is allows - // EVERYWHERE that uses this library directly or indirectly, which is undesirable. if (description.Status === 'CREATE_PENDING' || description.Status === 'CREATE_IN_PROGRESS') { await ioHelper.defaults.debug(format('Changeset %s on stack %s is still creating', changeSetDisplayName, stackDisplayName)); return undefined; } - const diag = await diagnoser.diagnoseChangeSet(description); - if (diag.type === 'no-problem') { - return description; - } - - throwDeploymentErrorFromDiagnosis(diag); + const diagnosis = await diagnoser.diagnoseChangeSet(description); + return { description, diagnosis }; }); if (!ret) { @@ -139,6 +129,26 @@ export async function waitForChangeSet( return ret; } +/** + * Waits for a ChangeSet to be available for triggering a StackUpdate. + * + * Will return a changeset that is either ready to be executed or has no changes. + * Will throw in other cases. + */ +export async function waitForChangeSet( + cfn: ICloudFormationClient, + ioHelper: IoHelper, + stackNameOrArn: string, + changeSetNameOrArn: string, + { fetchAll, diagnoser }: { fetchAll: boolean; diagnoser: CloudFormationStackDiagnoser }, +): Promise { + const { description, diagnosis } = await waitForChangeSetReport(cfn, ioHelper, stackNameOrArn, changeSetNameOrArn, { fetchAll, diagnoser }); + if (diagnosis.type !== 'no-problem') { + throwDeploymentErrorFromDiagnosis(diagnosis); + } + return description; +} + export async function waitForChangeSetGone( cfn: ICloudFormationClient, ioHelper: IoHelper, @@ -398,6 +408,78 @@ async function createChangeSetAndCleanup( return createdChangeSet; } +/** + * Create a change set for online validation (never executes, returns diagnosis instead of throwing). + */ +export async function createValidationChangeSet( + ioHelper: IoHelper, + options: Omit, +): Promise { + const env = await options.deployments.envs.accessStackForMutableStackOperations(options.stack); + await uploadStackTemplateAssets(options.stack, options.deployments); + + const bodyParameter = await makeBodyParameter( + ioHelper, + options.stack, + env.resolvedEnvironment, + new AssetManifestBuilder(), + env.resources, + ); + const cfn = env.sdk.cloudFormation(); + const stack = await CloudFormationStack.lookup(cfn, options.stack.stackName, false); + const exists = stack.exists && stack.stackStatus.name !== 'REVIEW_IN_PROGRESS'; + const executionRoleArn = await env.replacePlaceholders(options.stack.cloudFormationExecutionRoleArn); + const changeSetName = 'cdk-validate-change-set'; + + if (exists) { + await cleanupOldChangeset(cfn, ioHelper, changeSetName, options.stack.stackName); + } + + const templateParams = TemplateParameters.fromTemplate(options.stack.template); + const stackParams = templateParams.supplyAll(options.parameters); + + const changeSet = await cfn.createChangeSet({ + StackName: options.stack.stackName, + ChangeSetName: changeSetName, + ChangeSetType: exists ? 'UPDATE' : 'CREATE', + Description: `CDK Changeset for validation ${options.uuid}`, + ClientToken: `validate${options.uuid}`, + TemplateURL: bodyParameter.TemplateURL, + TemplateBody: bodyParameter.TemplateBody, + Parameters: stackParams.apiParameters, + IncludeNestedStacks: true, + RoleARN: executionRoleArn, + Tags: toCfnTags(options.stack.tags), + Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'], + }); + + const diagnoser = new CloudFormationStackDiagnoser({ + sdk: env.sdk, + envResources: env.resources, + sourceTracer: new StackArtifactSourceTracer(options.stack), + ioHelper, + topLevelStackHierarchicalId: options.stack.hierarchicalId, + }); + + const report = await waitForChangeSetReport( + cfn, ioHelper, + changeSet.StackId ?? options.stack.stackName, + changeSet.Id ?? changeSetName, + { fetchAll: false, diagnoser }, + ); + + await cleanupOldChangeset(cfn, ioHelper, changeSet.Id ?? changeSetName, changeSet.StackId ?? options.stack.stackName); + + if (!exists) { + await cfn.deleteStack({ + StackName: changeSet.StackId ?? options.stack.stackName, + ClientRequestToken: randomUUID(), + }); + } + + return report; +} + function toCfnTags(tags: { [id: string]: string }): Tag[] { return Object.entries(tags).map(([k, v]) => ({ Key: k, diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 0ee0e6441..1bde2ad0a 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1,7 +1,8 @@ import '../private/dispose-polyfill'; import * as path from 'node:path'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; -import type { FeatureFlagReportProperties, PolicyValidationReportConclusion } from '@aws-cdk/cloud-assembly-schema'; +import { randomUUID } from 'node:crypto'; +import type { FeatureFlagReportProperties, PolicyValidationReportConclusion, PluginReportJson } 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'; @@ -79,6 +80,7 @@ import { AsyncDisposableBox } from '../api/cloud-assembly/private/disposable-box import { CloudAssemblySourceBuilder } from '../api/cloud-assembly/source-builder'; import type { StackCollection } from '../api/cloud-assembly/stack-collection'; import { Deployments } from '../api/deployments'; +import { createValidationChangeSet } from '../api/deployments/cfn-api'; import { hostMessageFromDiagnosis } from '../api/diagnosing/diagnosis-formatting'; import { CloudFormationStackDiagnoser } from '../api/diagnosing/stack-diagnoser'; import { DiffFormatter } from '../api/diff'; @@ -664,27 +666,42 @@ export class Toolkit extends CloudAssemblySourceBuilder { const selectStacks = stacksOpt(options); await using assembly = await synthAndMeasure(ioHelper, cx, selectStacks); - const reportPath = path.join(assembly.directory, VALIDATION_REPORT_FILE); + const pluginReports: PluginReportJson[] = []; + let title: string | undefined; - if (!await fs.pathExists(reportPath)) { - const result: ValidateResult = { - conclusion: 'success', - pluginReports: [], - }; + // Offline validation: read the policy validation report from the cloud assembly + const reportPath = path.join(assembly.directory, VALIDATION_REPORT_FILE); + if (await fs.pathExists(reportPath)) { + const report = Manifest.loadValidationReport(reportPath); + title = report.title; + pluginReports.push(...report.pluginReports); + } else if (options.online === false) { await ioHelper.notify(IO.CDK_TOOLKIT_I9601.msg('No validation plugins configured. Add a plugin to your CDK app to enable validation.')); - return result; } - const report = Manifest.loadValidationReport(reportPath); + // Online validation: submit templates to CloudFormation for early validation + if (options.online ?? true) { + const stacks = await assembly.selectStacksV2(selectStacks); + const deployments = await this.deploymentsForAction('validate'); + + const onlineReport = await this.validateOnline(ioHelper, stacks, deployments); + if (onlineReport) { + pluginReports.push(onlineReport); + } + } - const conclusion: PolicyValidationReportConclusion = report.pluginReports.some( + if (pluginReports.length === 0) { + await ioHelper.notify(IO.CDK_TOOLKIT_I9601.msg('No validation plugins configured. Add a plugin to your CDK app to enable validation.')); + } + + const conclusion: PolicyValidationReportConclusion = pluginReports.some( (pr) => pr.conclusion === 'failure', ) ? 'failure' : 'success'; const result: ValidateResult = { conclusion, - title: report.title, - pluginReports: report.pluginReports, + title, + pluginReports, }; await ioHelper.notify(hostMessageFromValidation(result)); @@ -692,6 +709,56 @@ export class Toolkit extends CloudAssemblySourceBuilder { return result; } + private async validateOnline( + ioHelper: IoHelper, + stacks: StackCollection, + deployments: Deployments, + ): Promise { + const violations: PluginReportJson['violations'] = []; + + for (const stack of stacks.stackArtifacts) { + try { + const report = await createValidationChangeSet(ioHelper, { + deployments, + stack, + parameters: {}, + uuid: randomUUID(), + willExecute: false, + failOnError: true, + }); + + if (report.diagnosis.type === 'problem') { + for (const problem of report.diagnosis.problems) { + violations.push({ + ruleName: problem.errorCode ?? 'CloudFormationValidation', + description: problem.message, + severity: 'error', + violatingConstructs: [{ + constructPath: problem.logicalId ? `${stack.hierarchicalId}/${problem.logicalId}` : stack.hierarchicalId, + cloudFormationResource: problem.logicalId ? { + templatePath: `${stack.stackName}.template.json`, + logicalId: problem.logicalId, + } : undefined, + }], + }); + } + } + } catch (e: any) { + await ioHelper.defaults.warn(`Failed to run online validation for stack ${stack.stackName}: ${e.message}`); + } + } + + if (violations.length === 0) { + return undefined; + } + + return { + pluginName: 'CloudFormation', + conclusion: 'failure', + violations, + }; + } + /** * Deploy Action * 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..c0cf17345 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -13,7 +13,7 @@ beforeEach(() => { describe('validate', () => { test('returns failure when report contains failing plugin', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('failure'); expect(result.title).toBe('Validation Report'); @@ -27,7 +27,7 @@ describe('validate', () => { test('returns success when all plugins pass', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-passing-validation'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('success'); expect(result.pluginReports).toHaveLength(1); @@ -37,7 +37,7 @@ describe('validate', () => { test('returns success with no reports when no report file exists', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('success'); expect(result.pluginReports).toHaveLength(0); @@ -46,14 +46,14 @@ describe('validate', () => { test('emits info IO message on success', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-passing-validation'); - await toolkit.validate(cx); + await toolkit.validate(cx, { online: false }); ioHost.expectMessage({ containing: 'No violations found', level: 'info' }); }); test('can invoke without options', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('success'); }); @@ -62,6 +62,7 @@ describe('validate', () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); const result = await toolkit.validate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + online: false, }); expect(result.conclusion).toBe('failure'); @@ -69,7 +70,7 @@ describe('validate', () => { test('parses violation details correctly', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); const violation = result.pluginReports[0].violations[0]; expect(violation.severity).toBe('error'); @@ -82,7 +83,7 @@ describe('validate', () => { test('includes plugin version in report', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.pluginReports[0].pluginVersion).toBe('1.0.0'); expect(result.pluginReports[1].pluginVersion).toBeUndefined(); @@ -91,12 +92,12 @@ describe('validate', () => { test('throws on malformed report', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-malformed-validation-report'); - await expect(toolkit.validate(cx)).rejects.toThrow(); + await expect(toolkit.validate(cx, { online: false })).rejects.toThrow(); }); test('parses stack traces correctly', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); const construct = result.pluginReports[0].violations[0].violatingConstructs[0]; expect(construct.stackTraces).toBeDefined(); @@ -106,7 +107,7 @@ describe('validate', () => { test('IO message payload contains full ValidateResult', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - await toolkit.validate(cx); + await toolkit.validate(cx, { online: false }); const msg = ioHost.messages.find( (m) => m.code === 'CDK_TOOLKIT_I9600', @@ -126,7 +127,7 @@ describe('validate', () => { test('handles report with missing title field', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-no-title-validation'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('failure'); expect(result.title).toBeUndefined(); diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 7eaf972c4..e326b987e 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -223,7 +223,9 @@ export async function makeConfig(): Promise { }, 'validate': { description: 'Validate synthesized CloudFormation templates against policy rules', - options: {}, + options: { + 'online': { type: 'boolean', desc: 'Submit templates to CloudFormation for early validation (requires AWS credentials)', default: true }, + }, arg: { name: 'STACKS', variadic: true, diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 7987f5f4d..275535f2a 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -449,6 +449,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise Date: Wed, 20 May 2026 20:42:56 -0400 Subject: [PATCH 08/11] test(toolkit): add integ tests for cdk validate --online Tests cover: - Invalid resource type (AWS::Fake::DoesNotExist) is caught by CFN - Valid template passes online validation - --no-online flag skips CloudFormation validation --- .../cdk-apps/validate-online-app/app.js | 33 +++++++++++++++++++ .../cdk-apps/validate-online-app/cdk.json | 4 +++ ...-validate-no-online-skips-cfn.integtest.ts | 14 ++++++++ ...line-catches-invalid-resource.integtest.ts | 16 +++++++++ ...-online-passes-valid-template.integtest.ts | 12 +++++++ 5 files changed, 79 insertions(+) create mode 100644 packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js create mode 100644 packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-no-online-skips-cfn.integtest.ts create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-catches-invalid-resource.integtest.ts create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-passes-valid-template.integtest.ts diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js new file mode 100644 index 000000000..d9443ea58 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js @@ -0,0 +1,33 @@ +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 ValidStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + new cdk.CfnResource(this, 'WaitHandle', { + type: 'AWS::CloudFormation::WaitConditionHandle', + }); + } +} + +class InvalidStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + new cdk.CfnResource(this, 'BadResource', { + type: 'AWS::Fake::DoesNotExist', + properties: { + SomeProperty: 'value', + }, + }); + } +} + +const app = new cdk.App(); +new ValidStack(app, `${stackPrefix}-validate-online-valid`); +new InvalidStack(app, `${stackPrefix}-validate-online-invalid`); + +app.synth(); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json new file mode 100644 index 000000000..f0075b1c9 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json @@ -0,0 +1,4 @@ +{ + "app": "node app.js", + "versionReporting": false +} diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-no-online-skips-cfn.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-no-online-skips-cfn.integtest.ts new file mode 100644 index 000000000..eb684c774 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-no-online-skips-cfn.integtest.ts @@ -0,0 +1,14 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate --no-online skips CloudFormation validation', + withSpecificFixture('validate-online-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', '--no-online', fixture.fullStackName('validate-online-invalid')], + ); + + // With --no-online, the invalid resource type should NOT be caught + expect(output).not.toContain('AWS::Fake::DoesNotExist'); + expect(output).not.toContain('CloudFormation'); + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-catches-invalid-resource.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-catches-invalid-resource.integtest.ts new file mode 100644 index 000000000..3ddb77a7e --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-catches-invalid-resource.integtest.ts @@ -0,0 +1,16 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate --online catches invalid resource type', + withSpecificFixture('validate-online-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', '--online', fixture.fullStackName('validate-online-invalid')], + { + allowErrExit: true, + }, + ); + + expect(output).toContain('CloudFormation'); + expect(output).toContain('AWS::Fake::DoesNotExist'); + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-passes-valid-template.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-passes-valid-template.integtest.ts new file mode 100644 index 000000000..243151cb4 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-passes-valid-template.integtest.ts @@ -0,0 +1,12 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate --online passes for valid template', + withSpecificFixture('validate-online-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', '--online', fixture.fullStackName('validate-online-valid')], + ); + + expect(output).toContain('No violations found'); + }), +); From 747dffeff47a2d85aa88b0db93eee3b1120cd568 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Wed, 20 May 2026 20:55:35 -0400 Subject: [PATCH 09/11] refactor(toolkit): deduplicate cfn-api change set prep, add online unit tests Extract prepareChangeSetEnv() to share env setup between diff and validate change set creation. Restore JSDoc on waitForChangeSet. Add unit tests for the online validation path: - Reports CFN errors as a CloudFormation plugin report - Passes when CFN finds no problems - Merges offline + online results - Gracefully handles errors (warns instead of throwing) --- .../lib/api/deployments/cfn-api.ts | 81 +++++++------- .../toolkit-lib/test/actions/validate.test.ts | 104 ++++++++++++++++++ 2 files changed, 145 insertions(+), 40 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts index 2664e4649..4e4655c0f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts @@ -21,7 +21,6 @@ import { CloudFormationStack, makeBodyParameter } from '../cloudformation'; import type { StackDiagnosis } from '../../actions/diagnose'; import { throwDeploymentErrorFromDiagnosis } from '../diagnosing/diagnosis-formatting'; import { CloudFormationStackDiagnoser } from '../diagnosing/stack-diagnoser'; -import type { TargetEnvironment } from '../environment'; import type { IoHelper } from '../io/private'; import type { ResourcesToImport } from '../resource-import'; import { StackArtifactSourceTracer } from '../source-tracing/private/stack-source-tracing'; @@ -134,6 +133,12 @@ export async function waitForChangeSetReport( * * Will return a changeset that is either ready to be executed or has no changes. * Will throw in other cases. + * + * @param cfn a CloudFormation client + * @param stackNameOrArn the name or ARN of the Stack the ChangeSet belongs to, prefer ARN + * @param changeSetNameOrArn the name or ARN of the ChangeSet, prefer ARN + * @param fetchAll if true, fetches all pages of the ChangeSet before returning. + * @returns the CloudFormation description of the ChangeSet */ export async function waitForChangeSet( cfn: ICloudFormationClient, @@ -222,8 +227,7 @@ export async function createDiffChangeSet( options: Omit, ): Promise { try { - const env = await options.deployments.envs.accessStackForMutableStackOperations(options.stack); - return await uploadBodyParameterAndCreateChangeSet(ioHelper, env, { + return await uploadBodyParameterAndCreateChangeSet(ioHelper, { ...options, includeNestedStacks: true, }); @@ -268,14 +272,19 @@ function templatesFromAssetManifestArtifact( return [assetManifest, assets]; } -/** - * Only ever called for 'cdk diff' - */ -async function uploadBodyParameterAndCreateChangeSet( +interface PreparedChangeSetEnv { + cfn: ICloudFormationClient; + bodyParameter: TemplateBodyParameter; + exists: boolean; + executionRoleArn: string | undefined; + diagnoser: CloudFormationStackDiagnoser; +} + +async function prepareChangeSetEnv( ioHelper: IoHelper, - env: TargetEnvironment, - options: PrepareChangeSetOptions, -): Promise { + options: { stack: cxapi.CloudFormationStackArtifact; deployments: Deployments }, +): Promise { + const env = await options.deployments.envs.accessStackForMutableStackOperations(options.stack); await uploadStackTemplateAssets(options.stack, options.deployments); const bodyParameter = await makeBodyParameter( ioHelper, @@ -289,8 +298,24 @@ async function uploadBodyParameterAndCreateChangeSet( // A stack in REVIEW_IN_PROGRESS was created by a previous CREATE changeset // that was never executed. Treat it as non-existent for changeset purposes. const exists = stack.exists && stack.stackStatus.name !== 'REVIEW_IN_PROGRESS' && stack.stackStatus.name !== 'DELETE_IN_PROGRESS'; - const executionRoleArn = await env.replacePlaceholders(options.stack.cloudFormationExecutionRoleArn); + const diagnoser = new CloudFormationStackDiagnoser({ + sdk: env.sdk, + envResources: env.resources, + sourceTracer: new StackArtifactSourceTracer(options.stack), + ioHelper, + topLevelStackHierarchicalId: options.stack.hierarchicalId, + }); + + return { cfn, bodyParameter, exists, executionRoleArn, diagnoser }; +} + +async function uploadBodyParameterAndCreateChangeSet( + ioHelper: IoHelper, + options: PrepareChangeSetOptions, +): Promise { + const { cfn, bodyParameter, exists, executionRoleArn, diagnoser } = await prepareChangeSetEnv(ioHelper, options); + await ioHelper.defaults.info( 'Hold on while we create a read-only change set to get a diff with accurate replacement information (use --method=template to use a less accurate but faster template-only diff)\n', ); @@ -308,13 +333,7 @@ async function uploadBodyParameterAndCreateChangeSet( importExistingResources: options.importExistingResources, includeNestedStacks: options.includeNestedStacks, role: executionRoleArn, - diagnoser: new CloudFormationStackDiagnoser({ - sdk: env.sdk, - envResources: env.resources, - sourceTracer: new StackArtifactSourceTracer(options.stack), - ioHelper, - topLevelStackHierarchicalId: options.stack.hierarchicalId, - }), + diagnoser, }); } @@ -410,25 +429,15 @@ async function createChangeSetAndCleanup( /** * Create a change set for online validation (never executes, returns diagnosis instead of throwing). + * + * Uses the same env preparation as diff, but calls `waitForChangeSetReport` to return + * the diagnosis rather than throwing on failure. Always cleans up the change set afterwards. */ export async function createValidationChangeSet( ioHelper: IoHelper, options: Omit, ): Promise { - const env = await options.deployments.envs.accessStackForMutableStackOperations(options.stack); - await uploadStackTemplateAssets(options.stack, options.deployments); - - const bodyParameter = await makeBodyParameter( - ioHelper, - options.stack, - env.resolvedEnvironment, - new AssetManifestBuilder(), - env.resources, - ); - const cfn = env.sdk.cloudFormation(); - const stack = await CloudFormationStack.lookup(cfn, options.stack.stackName, false); - const exists = stack.exists && stack.stackStatus.name !== 'REVIEW_IN_PROGRESS'; - const executionRoleArn = await env.replacePlaceholders(options.stack.cloudFormationExecutionRoleArn); + const { cfn, bodyParameter, exists, executionRoleArn, diagnoser } = await prepareChangeSetEnv(ioHelper, options); const changeSetName = 'cdk-validate-change-set'; if (exists) { @@ -453,14 +462,6 @@ export async function createValidationChangeSet( Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'], }); - const diagnoser = new CloudFormationStackDiagnoser({ - sdk: env.sdk, - envResources: env.resources, - sourceTracer: new StackArtifactSourceTracer(options.stack), - ioHelper, - topLevelStackHierarchicalId: options.stack.hierarchicalId, - }); - const report = await waitForChangeSetReport( cfn, ioHelper, changeSet.StackId ?? options.stack.stackName, 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 c0cf17345..809933b04 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -1,11 +1,17 @@ import { StackSelectionStrategy } from '../../lib/api/cloud-assembly'; +import * as awsauth from '../../lib/api/aws-auth/private'; +import * as cfnApi from '../../lib/api/deployments/cfn-api'; import { Toolkit } from '../../lib/toolkit'; import { cdkOutFixture, TestIoHost } from '../_helpers'; +import { MockSdk, restoreSdkMocksToDefault, setDefaultSTSMocks } from '../_helpers/mock-sdk'; let ioHost: TestIoHost; let toolkit: Toolkit; beforeEach(() => { + jest.restoreAllMocks(); + restoreSdkMocksToDefault(); + setDefaultSTSMocks(); ioHost = new TestIoHost(); toolkit = new Toolkit({ ioHost }); }); @@ -135,3 +141,101 @@ describe('validate', () => { expect(result.pluginReports[0].violations[0].ruleName).toBe('no-public-buckets'); }); }); + +describe('validate --online', () => { + beforeEach(() => { + jest.spyOn(awsauth.SdkProvider.prototype, '_makeSdk').mockReturnValue(new MockSdk()); + }); + + test('reports CloudFormation validation errors as a plugin report', async () => { + jest.spyOn(cfnApi, 'createValidationChangeSet').mockResolvedValue({ + description: { $metadata: {} } as any, + diagnosis: { + type: 'problem', + detectedBy: { type: 'early-validation', changeSetName: 'cdk-validate-change-set' }, + problems: [ + { + stackArn: 'arn:aws:cloudformation:us-east-1:123456789012:stack/Stack1', + topLevelStackHierarchicalId: 'Stack1', + parentStackLogicalIds: [], + logicalId: 'BadResource', + resourceType: 'AWS::Fake::DoesNotExist', + message: 'Resource type AWS::Fake::DoesNotExist does not exist', + errorCode: 'InvalidResourceType', + sourceTrace: undefined, + }, + ], + }, + }); + + const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.validate(cx, { online: true }); + + expect(result.conclusion).toBe('failure'); + expect(result.pluginReports).toHaveLength(1); + expect(result.pluginReports[0].pluginName).toBe('CloudFormation'); + expect(result.pluginReports[0].conclusion).toBe('failure'); + expect(result.pluginReports[0].violations).toHaveLength(1); + expect(result.pluginReports[0].violations[0].ruleName).toBe('InvalidResourceType'); + expect(result.pluginReports[0].violations[0].description).toBe('Resource type AWS::Fake::DoesNotExist does not exist'); + expect(result.pluginReports[0].violations[0].violatingConstructs[0].cloudFormationResource?.logicalId).toBe('BadResource'); + }); + + test('passes when CloudFormation finds no problems', async () => { + jest.spyOn(cfnApi, 'createValidationChangeSet').mockResolvedValue({ + description: { $metadata: {} } as any, + diagnosis: { type: 'no-problem' }, + }); + + const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.validate(cx, { online: true }); + + expect(result.conclusion).toBe('success'); + expect(result.pluginReports).toHaveLength(0); + }); + + test('merges offline and online results', async () => { + jest.spyOn(cfnApi, 'createValidationChangeSet').mockResolvedValue({ + description: { $metadata: {} } as any, + diagnosis: { + type: 'problem', + detectedBy: { type: 'early-validation', changeSetName: 'cdk-validate-change-set' }, + problems: [ + { + stackArn: 'arn:aws:cloudformation:us-east-1:123456789012:stack/Stack1', + topLevelStackHierarchicalId: 'Stack1', + parentStackLogicalIds: [], + logicalId: 'MyBucket', + message: 'Property validation failure', + errorCode: 'InvalidProperty', + sourceTrace: undefined, + }, + ], + }, + }); + + const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); + const result = await toolkit.validate(cx, { online: true }); + + expect(result.conclusion).toBe('failure'); + // 2 from offline report + 1 from online + expect(result.pluginReports).toHaveLength(3); + expect(result.pluginReports[0].pluginName).toBe('TestPlugin'); + expect(result.pluginReports[1].pluginName).toBe('Construct Annotations'); + expect(result.pluginReports[2].pluginName).toBe('CloudFormation'); + }); + + test('gracefully handles online validation failure', async () => { + jest.spyOn(cfnApi, 'createValidationChangeSet').mockRejectedValue( + new Error('Access denied'), + ); + + const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.validate(cx, { online: true }); + + // Should not throw, just warn and return success (no offline report either) + expect(result.conclusion).toBe('success'); + expect(result.pluginReports).toHaveLength(0); + ioHost.expectMessage({ containing: 'Failed to run online validation', level: 'warn' }); + }); +}); From 703b1258230a74d349a3d0a12c0cde53bc51c201 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Thu, 21 May 2026 19:34:17 -0400 Subject: [PATCH 10/11] fix(toolkit): wire --online flag in parser, report CFN errors as violations - Add --online option to parse-command-line-arguments.ts so yargs recognizes it instead of warning "Unknown option" - Report CloudFormation errors (like template format errors) as violations instead of swallowing them with a warning --- packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts | 11 +++++++++-- .../toolkit-lib/test/actions/validate.test.ts | 12 ++++++------ .../aws-cdk/lib/cli/parse-command-line-arguments.ts | 8 +++++++- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 1bde2ad0a..d58919725 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -732,7 +732,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { violations.push({ ruleName: problem.errorCode ?? 'CloudFormationValidation', description: problem.message, - severity: 'error', + severity: 'fatal', violatingConstructs: [{ constructPath: problem.logicalId ? `${stack.hierarchicalId}/${problem.logicalId}` : stack.hierarchicalId, cloudFormationResource: problem.logicalId ? { @@ -744,7 +744,14 @@ export class Toolkit extends CloudAssemblySourceBuilder { } } } catch (e: any) { - await ioHelper.defaults.warn(`Failed to run online validation for stack ${stack.stackName}: ${e.message}`); + violations.push({ + ruleName: 'CloudFormationValidation', + description: e.message, + severity: 'fatal', + violatingConstructs: [{ + constructPath: stack.hierarchicalId, + }], + }); } } 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 809933b04..6f1682d47 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -225,17 +225,17 @@ describe('validate --online', () => { expect(result.pluginReports[2].pluginName).toBe('CloudFormation'); }); - test('gracefully handles online validation failure', async () => { + test('reports thrown errors as violations', async () => { jest.spyOn(cfnApi, 'createValidationChangeSet').mockRejectedValue( - new Error('Access denied'), + new Error('Template format error: Unrecognized resource types: [AWS::Fake::DoesNotExist]'), ); const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); const result = await toolkit.validate(cx, { online: true }); - // Should not throw, just warn and return success (no offline report either) - expect(result.conclusion).toBe('success'); - expect(result.pluginReports).toHaveLength(0); - ioHost.expectMessage({ containing: 'Failed to run online validation', level: 'warn' }); + expect(result.conclusion).toBe('failure'); + expect(result.pluginReports).toHaveLength(1); + expect(result.pluginReports[0].pluginName).toBe('CloudFormation'); + expect(result.pluginReports[0].violations[0].description).toContain('Unrecognized resource types'); }); }); diff --git a/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts b/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts index 7c9cbd5a4..bcce88eac 100644 --- a/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts +++ b/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts @@ -617,7 +617,13 @@ export function parseCommandLineArguments(args: Array): 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('validate [STACKS..]', 'Validate synthesized CloudFormation templates against policy rules', (yargs: Argv) => yargs + .option('online', { + type: 'boolean', + desc: 'Submit templates to CloudFormation for early validation (requires AWS credentials)', + default: true, + }), + ) .command('diagnose [STACKS..]', 'Find the root cause(s) of stack deployment failures', (yargs: Argv) => yargs .option('toolkit-stack-name', { From eba22ad60b61d36f3d63ce6a25fdc37e89d9e1f2 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Thu, 21 May 2026 21:22:10 -0400 Subject: [PATCH 11/11] test(toolkit): add combined offline+online validation integ test Adds a CombinedStack fixture with both an S3 bucket (triggers SecurityPlugin offline) and AWS::Fake::DoesNotExist (rejected by CFN online). Test verifies both violations appear in one report. --- .../cdk-apps/validate-online-app/app.js | 62 +++++++++++++++++-- .../cdk-apps/validate-online-app/cdk.json | 5 +- ...e-combined-offline-and-online.integtest.ts | 21 +++++++ 3 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-combined-offline-and-online.integtest.ts diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js index d9443ea58..e29b80c96 100644 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js @@ -1,10 +1,46 @@ +const fs = require('fs'); 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 = '1.0.0'; + } + + validate(context) { + 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: 'error', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/PublicAccessBlockConfiguration`], + }], + }); + } + } + } + return { success: violations.length === 0, violations }; + } +} + +const app = new cdk.App(); +cdk.Validations.of(app).addPlugins(new SecurityPlugin()); + +// Valid stack — no offline or online errors class ValidStack extends cdk.Stack { constructor(scope, id, props) { super(scope, id, props); @@ -14,20 +50,34 @@ class ValidStack extends cdk.Stack { } } -class InvalidStack extends cdk.Stack { +// Invalid stack (online only) — CFN rejects the resource type +class OnlineInvalidStack extends cdk.Stack { constructor(scope, id, props) { super(scope, id, props); new cdk.CfnResource(this, 'BadResource', { type: 'AWS::Fake::DoesNotExist', - properties: { - SomeProperty: 'value', - }, + properties: { SomeProperty: 'value' }, + }); + } +} + +// Combined stack — has BOTH offline (S3 bucket triggers SecurityPlugin) +// AND online errors (invalid resource type rejected by CFN) +class CombinedStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + new s3.Bucket(this, 'MyBucket', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + new cdk.CfnResource(this, 'BadResource', { + type: 'AWS::Fake::DoesNotExist', + properties: { SomeProperty: 'value' }, }); } } -const app = new cdk.App(); new ValidStack(app, `${stackPrefix}-validate-online-valid`); -new InvalidStack(app, `${stackPrefix}-validate-online-invalid`); +new OnlineInvalidStack(app, `${stackPrefix}-validate-online-invalid`); +new CombinedStack(app, `${stackPrefix}-validate-online-combined`); app.synth(); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json index f0075b1c9..791696b03 100644 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json @@ -1,4 +1,7 @@ { "app": "node app.js", - "versionReporting": false + "versionReporting": false, + "context": { + "@aws-cdk/core:failSynthOnValidationErrors": false + } } diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-combined-offline-and-online.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-combined-offline-and-online.integtest.ts new file mode 100644 index 000000000..a62e7b5e5 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-combined-offline-and-online.integtest.ts @@ -0,0 +1,21 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate --online reports both offline and online errors', + withSpecificFixture('validate-online-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', '--online', fixture.fullStackName('validate-online-combined')], + { + allowErrExit: true, + }, + ); + + // Offline: SecurityPlugin catches the S3 bucket + expect(output).toContain('S3 Buckets must not be publicly accessible'); + expect(output).toContain('SecurityPlugin'); + + // Online: CloudFormation rejects the fake resource type + expect(output).toContain('AWS::Fake::DoesNotExist'); + expect(output).toContain('CloudFormation'); + }), +);