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 fc6fc15ce..6f4987d08 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 @@ -40,7 +40,9 @@ class SecurityPlugin { } const app = new cdk.App(); -cdk.Validations.of(app).addPlugins(new SecurityPlugin()); +if (process.env.INJECT_OFFLINE_ERRORS) { + cdk.Validations.of(app).addPlugins(new SecurityPlugin()); +} // Valid stack — no offline or online errors class ValidStack extends cdk.Stack { 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 index 8f0698d16..befa396f9 100644 --- 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 @@ -14,6 +14,9 @@ integTest( const output = await fixture.cdk( ['--unstable=validate', 'validate', '--online', fixture.fullStackName('validate-online-combined')], { + modEnv: { + INJECT_OFFLINE_ERRORS: 'true', + }, allowErrExit: true, }, ); 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 index fe68efe12..0527ebfc9 100644 --- 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 @@ -7,6 +7,6 @@ integTest( ['--unstable=validate', 'validate', '--online', fixture.fullStackName('validate-online-valid')], ); - expect(output).toContain('No problems found'); + expect(output).toContain('Validation did not find any problems'); }), ); 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 05d721eaf..8bc57ae35 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 @@ -7,6 +7,6 @@ integTest( ['--unstable=validate', 'validate', fixture.fullStackName('validate-passing')], ); - expect(output).toContain('Policy validation passed. No problems found.'); + expect(output).toContain('Validation did not find any problems.'); }), ); diff --git a/packages/@aws-cdk/cloud-assembly-api/lib/metadata.ts b/packages/@aws-cdk/cloud-assembly-api/lib/metadata.ts index bed769c23..d919db1ac 100644 --- a/packages/@aws-cdk/cloud-assembly-api/lib/metadata.ts +++ b/packages/@aws-cdk/cloud-assembly-api/lib/metadata.ts @@ -25,6 +25,9 @@ export type StackMetadata = { [path: string]: cxschema.MetadataEntry[] }; export interface SynthesisMessage { readonly level: SynthesisMessageLevel; + /** + * The construct path for the construct that emitted this message. + */ readonly id: string; readonly entry: cxschema.MetadataEntry; } diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index 3206c3d3d..d40e7dc66 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -146,7 +146,7 @@ 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` | Policy validation passed | `info` | {@link ValidateResult} | +| `CDK_TOOLKIT_I9600` | Validation did not find any problems | `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_W9602` | Online validation could not be completed for a stack | `warn` | n/a | 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 ee4f3a09d..dc7f88111 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts @@ -32,6 +32,8 @@ export interface ValidateResult { /** * The title of the validation report + * + * @deprecated This field is never populated */ readonly title?: string; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 40daa9aab..00063be43 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -74,6 +74,10 @@ export function contextFromSettings( const bundlingStacks = settings.get(['bundlingStacks']) ?? ['**']; context[cxapi.BUNDLING_STACKS] = bundlingStacks; + // We unconditionally tell the CDK app that the toolkit/CLI will handle validation reports. + // The app never has to exit with an error code because of it. + context[cxapi.FAIL_SYNTH_ON_VALIDATION_ERRORS_CONTEXT] = false; + return context; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-collection.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-collection.ts index cfc4fe376..e90942470 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-collection.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-collection.ts @@ -94,6 +94,8 @@ export class StackCollection { /** * Extracts 'aws:cdk:warning|info|error' metadata entries from the stack synthesis + * + * @deprecated The formatting of this function is lackluster. Use `throwIfValidationFailures()` instead. */ public async validateMetadata( failAt: 'warn' | 'error' | 'none' = 'error', 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 bc9a819b5..d13911828 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,7 +508,7 @@ export const IO = { // validate (96xx) CDK_TOOLKIT_I9600: make.info({ code: 'CDK_TOOLKIT_I9600', - description: 'Policy validation passed', + description: 'Validation did not find any problems', interface: 'ValidateResult', }), 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 1d132d919..a84adc666 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,46 +1,26 @@ import * as path from 'node:path'; -import type { PluginReportJson, ViolatingConstructJson } from '@aws-cdk/cloud-assembly-schema'; +import type { PluginReportJson, PolicyViolationJson, PolicyViolationSeverity, ViolatingConstructJson } from '@aws-cdk/cloud-assembly-schema'; import * as chalk from 'chalk'; import type { ValidateResult } from '../../actions/validate'; import type { ActionLessMessage } from '../io/private'; import { IO } from '../io/private'; -// Matches C0 control chars (except \t and \n), DEL, and CSI (8-bit mode). -// Strips ANSI escape sequences, carriage returns, backspaces, BEL, and -// bidirectional overrides that could spoof terminal output. -const CONTROL_CHARS = /[\x00-\x08\x0B-\x1F\x7F\x9B]/g; -function sanitize(s: string | undefined): string { - return (s ?? '').replace(CONTROL_CHARS, '�'); -} - -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 { +export function hostMessageFromValidation(fileRoot: string, result: ValidateResult): ActionLessMessage { // 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); + return IO.CDK_TOOLKIT_E9600.msg(formatValidateResult(fileRoot, result), result); +} + +export function formatValidateResult(fileRoot: string, result: ValidateResult): string { + return formatValidationReports(fileRoot, result.pluginReports).join('\n\n'); } -export function formatValidateResult(result: ValidateResult): string { - const violations = flattenViolations(result.pluginReports); +export function formatValidationReports(fileRoot: string, reports: PluginReportJson[]): string[] { + const successfullyExecutedPlugins = reports.filter((r) => isPluginFailure(r) === undefined); + const pluginFailures = reports.map(isPluginFailure).filter((e) => e !== undefined); - if (violations.length === 0) { - return '\nPolicy validation passed. No problems found.'; - } + const violations = flattenViolations(successfullyExecutedPlugins); violations.sort((a, b) => { const aOrder = SEVERITY_ORDER[a.severity.toLowerCase()] ?? 4; @@ -48,59 +28,68 @@ export function formatValidateResult(result: ValidateResult): string { return aOrder - bOrder; }); - const title = result.title ?? 'Validation Report'; - const blocks = violations.map((v) => formatViolationBlock(v)); - return `\n${title}\n${'-'.repeat(title.length)}\n\n${blocks.join('\n\n')}`; + return [ + ...pluginFailures.map(formatPluginFailure), + ...violations.map((v) => formatViolationBlock(fileRoot, v)), + ]; } -function flattenViolations(pluginReports: PluginReportJson[]): FlattenedViolation[] { - const result: FlattenedViolation[] = []; - - for (const report of pluginReports) { +function flattenViolations(reports: PluginReportJson[]): FlattenedViolation[] { + return reports.flatMap((report) => { 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; + return report.violations.flatMap((violation) => { + return violation.violatingConstructs.map((construct) => ({ + severity: normalizeSeverity(violation.severity), + description: violation.description, + ruleName: violation.ruleName, + pluginName, + construct, + suggestedFix: violation.suggestedFix, + ruleMetadata: violation.ruleMetadata, + })); + }); + }); } -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'; - const safe = sanitize(severity); - return safe.charAt(0).toUpperCase() + safe.slice(1); +function normalizeSeverity(severity: PolicyViolationSeverity, customSeverity?: string): string { + switch (severity) { + case 'fatal': + case 'error': + case 'warning': + case 'info': + return severity.toUpperCase(); + case 'custom': + return customSeverity ?? 'INFO'; + } } -function formatViolationBlock(v: FlattenedViolation): string { +function formatViolationBlock(fileRoot: string, v: FlattenedViolation): string { const lines: string[] = []; - const location = getLeafLocation(v.construct.stackTraces); + const location = sourceLocation(fileRoot, v.construct.stackTraces); if (location) { lines.push(chalk.underline(sanitize(location))); } - const severityColor = getSeverityColor(v.severity); - const description = stripAckTag(sanitize(v.description)); - const severityAndDesc = severityColor(chalk.bold(`${v.severity}: ${description}`)); - lines.push(`${severityAndDesc} ${sanitize(v.pluginName)}`); + lines.push([ + chalk.bold(getSeverityColor(v.severity)(sanitize(v.severity))), + chalk.bold(stripAckTag(sanitize(v.description))), + chalk.grey(`(${sanitize(v.pluginName)})`), + ].join(' ')); - const constructInfo = formatConstructInfo(v.construct); + const constructInfo = formatConstructInfo(fileRoot, v.construct); lines.push(` ${constructInfo}`); - if (v.severity.toLowerCase() !== 'fatal') { + if (v.suggestedFix) { + lines.push(` Suggested fix: ${sanitize(v.suggestedFix).replace(/\n/g, '\n ')}`); + } + + if (isSuppressibleViolation(v)) { const ackId = `${sanitize(v.pluginName)}::${sanitize(v.ruleName)}`.replace(/ /g, '-'); - lines.push(` Acknowledge '${ackId}'`); + lines.push(` ${chalk.grey(`Acknowledge with '${ackId}'`)}`); + } else { + // If not acknowledgeable, we should still show the rule name for reference. + lines.push(` ${chalk.grey(`Rule ${sanitize(v.ruleName)}`)}`); } return lines.join('\n'); @@ -115,19 +104,29 @@ function getSeverityColor(severity: string): (str: string) => string { } } -function formatConstructInfo(construct: ViolatingConstructJson): string { +function formatPluginFailure(f: PluginError): string { + return `${chalk.ansi256(208)('ERROR')} ${sanitize(f.error)}`; +} + +function formatConstructInfo(fileRoot: string, construct: ViolatingConstructJson): string { const parts: string[] = []; const logicalId = sanitize(construct.cloudFormationResource?.logicalId); if (construct.constructPath) { const cPath = sanitize(construct.constructPath); parts.push(logicalId ? `${chalk.bold(cPath)} (${logicalId})` : chalk.bold(cPath)); - } else if (logicalId) { - parts.push(chalk.bold(logicalId)); + } else { + // No construct information, show template path and logical ID + if (construct.cloudFormationResource?.templatePath) { + parts.push(humanFriendlyFilename(fileRoot, sanitize(construct.cloudFormationResource.templatePath))); + } + if (logicalId) { + parts.push(chalk.bold(logicalId)); + } } if (construct.constructFqn) { - parts.push(sanitize(construct.constructFqn)); + parts.push(chalk.grey(sanitize(construct.constructFqn))); } return parts.join(' '); @@ -137,10 +136,18 @@ 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]; - const frames = lastTrace.split('\n'); +function sourceLocation(fileRoot: string, stackTraces: string[] | undefined): string | undefined { + for (const trace of stackTraces ?? []) { + const frame = getLeafLocation(trace); + if (frame && frame.fileName) { + return `${humanFriendlyFilename(fileRoot, frame.fileName)}:${frame.sourceLocation}`; + } + } + return undefined; +} + +function getLeafLocation(stackTrace: string) { + const frames = stackTrace.split('\n'); if (frames.length === 0) return undefined; // Find the first frame that's user code (not in node_modules or aws-cdk-lib) @@ -149,5 +156,53 @@ function getLeafLocation(stackTraces: string[] | undefined): string | undefined const match = frame.match(/\((.+)\)$/) || frame.match(/at\s+(.+)$/); const location = match ? match[1] : frame; - return path.isAbsolute(location.split(':')[0]) ? path.relative(process.cwd(), location) : location; + return { fileName: location.split(':')[0], sourceLocation: location.split(':').slice(1).join(':') }; +} + +// Matches C0 control chars (except \t and \n), DEL, and CSI (8-bit mode). +// Strips ANSI escape sequences, carriage returns, backspaces, BEL, and +// bidirectional overrides that could spoof terminal output. +const CONTROL_CHARS = /[\x00-\x08\x0B-\x1F\x7F\x9B]/g; +function sanitize(s: string | undefined): string { + return (s ?? '').replace(CONTROL_CHARS, '�'); +} + +export type FlattenedViolation = + & Pick + & Pick + & { severity: string; construct: ViolatingConstructJson }; + +const SEVERITY_ORDER: Record = { + fatal: 0, + error: 1, + warning: 2, + info: 3, +}; + +export function humanFriendlyFilename(root: string, filename: string): string { + const absPath = filename; + const relPath = path.relative(root, filename); + return relPath.length < absPath.length ? relPath : absPath; +} + +interface PluginError { + readonly error: string; +} + +function isPluginFailure(r: PluginReportJson): PluginError | undefined { + if (r.conclusion === 'success' || r.violations.length > 0 || !r.metadata?.error) { + return undefined; + } + return { error: r.metadata.error }; +} + +/** + * Report whether it is possible to suppress this violation. + * + * Violations that are reported as "fatal", or that have been converted from annotations, cannot be suppressed. + */ +function isSuppressibleViolation(violation: { severity?: string; ruleMetadata?: { [key: string]: string } }): boolean { + const isFatal = violation.severity?.toLowerCase() === 'fatal'; + const isErrorAnnotation = violation.ruleMetadata?.['cdk:annotation'] && violation.severity?.toLowerCase() === 'error'; + return !isFatal && !isErrorAnnotation; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/collect-annotation-report.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/collect-annotation-report.ts new file mode 100644 index 000000000..f46d0c07b --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/collect-annotation-report.ts @@ -0,0 +1,95 @@ +import * as cxapi from '@aws-cdk/cloud-assembly-api'; +import type * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import type { StackCollection } from '../../api/cloud-assembly/stack-collection'; + +const ANNOTATION_PLUGIN_NAME = 'Construct Annotations'; + +/** + * Collect annotation metadata (warnings and errors) from the construct tree + * and convert them into a NamedValidationPluginReport that can be merged + * into the same report pipeline as plugin violations. + * + * Effectively the same as what happens here: + * + */ +export function collectAnnotationReport(stacks: StackCollection): cxschema.PluginReportJson { + // The return type requires that we combine violations by rule, so we have to group them first here. + const ruleMap = new Map(); + + for (const stack of stacks.stackArtifacts) { + for (const entry of stack.messages) { + let severity: cxschema.PolicyViolationSeverity | undefined; + + switch (entry.level) { + case cxapi.SynthesisMessageLevel.WARNING: + severity = 'warning'; + break; + case cxapi.SynthesisMessageLevel.ERROR: + severity = 'error'; + break; + case cxapi.SynthesisMessageLevel.INFO: + severity = 'info'; + break; + } + + const { message, ruleName } = splitDescriptionAndId(String(entry.entry.data)); + const ruleKey = `${ruleName}|${severity}|${message}`; + let violation = ruleMap.get(ruleKey); + if (!violation) { + violation = { + ruleName: ruleName ?? `${severity}-annotation`, + description: message, + severity, + violatingConstructs: [], + ruleMetadata: { + 'cdk:annotation': 'true', + }, + }; + ruleMap.set(ruleKey, violation); + } + + violation.violatingConstructs.push({ + constructPath: entry.id.replace(/^\//, ''), // remove leading slash + + // TODO: see if this information can be obtained from tree.json + // cloudFormationResource + // constructFqn: + // libraryVersion + + // TODO: see if we can get this from metadata stack traces. We may need to re-enable them for + // annotations in the core library. Otherwise we should probably get a stack trace to the resource itself. + // stackTraces + }); + } + } + + const violations = Array.from(ruleMap.values()); + const hasErrors = violations.some(v => v.severity === 'error'); + return { + pluginName: ANNOTATION_PLUGIN_NAME, + conclusion: hasErrors ? 'failure' : 'success', + violations, + }; +} + +/** + * Annotations have IDs in two places: + * + * - Warnings have `[ack:]` in the message. + * - Errors have `(::)` in the message. + * + * Separate the rule name from the rest of the description. + */ +function splitDescriptionAndId(message: string): { message: string; ruleName?: string } { + const ackMatch = message.match(/\[ack: ([^\]]+)\]/); + if (ackMatch) { + return { message: message.replace(ackMatch[0], '').trim(), ruleName: ackMatch[1] }; + } + + const idMatch = message.match(/\(([^()]+::[^()]+)\)$/); + if (idMatch) { + return { message: message.replace(idMatch[0], '').trim(), ruleName: idMatch[1] }; + } + + return { message }; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/validation-report.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/validation-report.ts new file mode 100644 index 000000000..a1a9eeb47 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/validation-report.ts @@ -0,0 +1,144 @@ +import * as path from 'path'; +import type { PluginReportJson, PolicyValidationReportConclusion } from '@aws-cdk/cloud-assembly-schema'; +import { Manifest } from '@aws-cdk/cloud-assembly-schema'; +import * as fs from 'fs-extra'; +import { collectAnnotationReport } from './collect-annotation-report'; +import type { ValidateResult } from '../../actions/validate'; +import type { StackCollection } from '../../api/cloud-assembly/stack-collection'; +import type { IoHelper } from '../../api/io/private/io-helper'; +import { hostMessageFromValidation } from '../../api/validate/validate-formatting'; +import { AssemblyError } from '../toolkit-error'; +import type { MinimumSeverity } from '../types'; + +const VALIDATION_REPORT_FILE = 'validation-report.json'; + +/** + * The name of the plugin that emits construct annotations into the validation report. + * + * @see https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/core/lib/private/annotation-plugin.ts + */ +const CONSTRUCT_ANNOTATIONS_PLUGINNAME = 'Construct Annotations'; + +interface AssemblyLike { + readonly directory: string; +} + +/** + * Return a validation report that contains the validation report that the CDK app has written, as well as any Construct Metadata annotations in the manifest. + * + * This function takes into account the CDK app can already have written the + * construct annotations into the validation report, or not, depending on the + * setting of a deprecated feature flag. If the annotations are already in the report, + * they are not copied. + * + * Afterwards, the list of violations is filtered to only include those that are relevant to the stacks selected for validation. + * + * Returns whether an explicit report file was found or not. + */ +export async function obtainUnifiedValidationReport(assembly: AssemblyLike, stacks: StackCollection): Promise { + const ret: PluginReportJson[] = []; + + const reportPath = path.join(assembly.directory, VALIDATION_REPORT_FILE); + if (await fs.pathExists(reportPath)) { + const selectedStackIds = new Set(stacks.hierarchicalIds); + const report = Manifest.loadValidationReport(reportPath); + + // Filter the report to only include violations for the selected stacks + const filteredReports = filterReportsByStacks(report.pluginReports, selectedStackIds); + ret.push(...filteredReports); + } + + const alreadyHasAnnotations = ret.some((r) => r.pluginName === CONSTRUCT_ANNOTATIONS_PLUGINNAME); + if (!alreadyHasAnnotations) { + const annotationReport = collectAnnotationReport(stacks); + if (annotationReport.violations.length > 0 || annotationReport.conclusion === 'failure') { + ret.push(annotationReport); + } + } + + // Remove all inconsequential reports + return ret; +} + +/** + * Return a success/failure conclusion from the given report + */ +export function combineConclusions(reports: PluginReportJson[]): PolicyValidationReportConclusion { + const reportHasFailures = reports.some((r) => r.conclusion === 'failure'); + return reportHasFailures ? 'failure' : 'success'; +} + +/** + * For operations that are NOT `cdk validate`, read the validation report and produce a failure if validation failed. + * + * Validation failed if there are any plugin reports with a failure conclusion, or if there are any warnings and the assembly is in strict mode. + */ +export async function throwIfValidationFailures( + assembly: AssemblyLike, + stacks: StackCollection, + failAt: MinimumSeverity, + ioHelper: IoHelper, +): Promise { + const pluginReports = await obtainUnifiedValidationReport(assembly, stacks); + if (pluginReports.length === 0) { + return; + } + + const conclusion = combineConclusions(pluginReports); + const result: ValidateResult = { conclusion, pluginReports }; + await ioHelper.notify(hostMessageFromValidation(process.cwd(), result)); + + switch (failAt) { + case 'error': + if (conclusion === 'failure') { + const error = AssemblyError.withStacks('Synthesis finished with errors', stacks.stackArtifacts); + error.attachSynthesisErrorCode('AnnotationErrors'); + throw error; + } + break; + case 'warn': + // if we're failing at 'warn', then both warnings and errors cause failure, so the initial conclusion is correct + if (conclusion === 'failure' || hasWarnings(pluginReports)) { + const error = AssemblyError.withStacks('Synthesis finished with warnings (--strict mode)', stacks.stackArtifacts); + error.attachSynthesisErrorCode('StrictAnnotationWarnings'); + throw error; + } + + break; + case 'none': + // if we're not failing at all, then the conclusion is always success + break; + } +} + +function hasWarnings(reports: PluginReportJson[]): boolean { + return reports.some((r) => r.violations.some((v) => v.severity === 'warning')); +} + +/** + * Remove violations that aren't in onde of the given stacks + */ +function filterReportsByStacks(reports: PluginReportJson[], selectedStackIds: Set): PluginReportJson[] { + return reports.map((report) => { + const filteredViolations = report.violations.filter((violation) => { + if (violation.violatingConstructs.length === 0) return true; + return violation.violatingConstructs.some((c) => + selectedStackIds.has(c.constructPath?.split('/')[0] ?? ''), + ); + }).map((violation) => { + if (violation.violatingConstructs.length === 0) return violation; + return { + ...violation, + violatingConstructs: violation.violatingConstructs.filter((c) => + selectedStackIds.has(c.constructPath?.split('/')[0] ?? ''), + ), + }; + }); + + return { + ...report, + violations: filteredViolations, + conclusion: filteredViolations.length > 0 ? report.conclusion : ('success' as const), + }; + }); +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index f48bc5dc6..3a430a74a 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -2,8 +2,8 @@ import '../private/dispose-polyfill'; import { randomUUID } from 'node:crypto'; import * as path from 'node:path'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; -import type { FeatureFlagReportProperties, PolicyValidationReportConclusion, PluginReportJson } from '@aws-cdk/cloud-assembly-schema'; -import { ArtifactType, Manifest } from '@aws-cdk/cloud-assembly-schema'; +import type { FeatureFlagReportProperties, PluginReportJson } from '@aws-cdk/cloud-assembly-schema'; +import { ArtifactType } from '@aws-cdk/cloud-assembly-schema'; import type { TemplateDiff } from '@aws-cdk/cloudformation-diff'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; @@ -28,7 +28,7 @@ import { NonInteractiveIoHost } from './non-interactive-io-host'; import type { ToolkitServices } from './private'; import { assemblyFromSource } from './private'; import { ToolkitError } from './toolkit-error'; -import type { DeployResult, DestroyResult, FeatureFlag, RollbackResult } from './types'; +import type { DeployResult, DestroyResult, FeatureFlag, MinimumSeverity, RollbackResult } from './types'; import type { BootstrapEnvironments, BootstrapOptions, @@ -86,7 +86,7 @@ import { CloudFormationStackDiagnoser } from '../api/diagnosing/stack-diagnoser' import { DiffFormatter } from '../api/diff'; import { detectStackDrift } from '../api/drift'; import { DriftFormatter } from '../api/drift/drift-formatter'; -import type { IIoHost, IoMessageLevel, ToolkitAction } from '../api/io'; +import type { IIoHost, ToolkitAction } from '../api/io'; 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'; @@ -116,8 +116,7 @@ import { formatErrorMessage, formatTime, obscureTemplate, serializeStructure, va import { pLimit } from '../util/concurrency'; import { createIgnoreMatcher } from '../util/glob-matcher'; import { promiseWithResolvers } from '../util/promises'; - -const VALIDATION_REPORT_FILE = 'validation-report.json'; +import { combineConclusions, obtainUnifiedValidationReport, throwIfValidationFailures } from './private/validation-report'; export interface ToolkitOptions { /** @@ -155,11 +154,11 @@ export interface ToolkitOptions { readonly toolkitStackName?: string; /** - * Fail Cloud Assemblies + * Fail Cloud Assembly operations with an error if there are validation errors in the assembly with at least the indicated severity. * * @default "error" */ - readonly assemblyFailureAt?: 'error' | 'warn' | 'none'; + readonly assemblyFailureAt?: MinimumSeverity; /** * The plugin host to use for loading and querying plugins @@ -214,9 +213,12 @@ export class Toolkit extends CloudAssemblySourceBuilder { private readonly unstableFeatures: Array; + private readonly assemblyFailureAt: MinimumSeverity; + public constructor(private readonly props: ToolkitOptions = {}) { super(); this.toolkitStackName = props.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME; + this.assemblyFailureAt = props.assemblyFailureAt ?? 'error'; this.pluginHost = props.pluginHost ?? new PluginHost(); @@ -345,7 +347,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { await using assembly = new AsyncDisposableBox(await synthAndMeasure(ioHelper, cx, stacksOpt(options))); const stacks = await assembly.value.selectStacksV2(stacksOpt(options)); const autoValidateStacks = options.validateStacks ? [assembly.value.selectStacksForValidation()] : []; - await this.validateStacksMetadata(stacks.concat(...autoValidateStacks), ioHelper); + await throwIfValidationFailures(assembly.value, stacks.concat(...autoValidateStacks), this.assemblyFailureAt, ioHelper); // if we have a single stack, print it to STDOUT const message = `Successfully synthesized to ${chalk.blue(path.resolve(stacks.assembly.directory))}`; @@ -526,7 +528,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { await using assembly = await synthAndMeasure(ioHelper, cx, selectStacks); const stackCollection = await assembly.selectStacksV2(selectStacks); - await this.validateStacksMetadata(stackCollection, ioHelper); + await throwIfValidationFailures(assembly, stackCollection, this.assemblyFailureAt, ioHelper); if (stackCollection.stackCount === 0) { await ioHelper.notify(IO.CDK_TOOLKIT_E5001.msg('No stacks selected')); @@ -667,25 +669,12 @@ export class Toolkit extends CloudAssemblySourceBuilder { public async validate(cx: ICloudAssemblySource, options: ValidateOptions = {}): Promise { const ioHelper = asIoHelper(this.ioHost, 'validate'); const selectStacks = stacksOpt(options); + await using assembly = await synthAndMeasure(ioHelper, cx, selectStacks); - const pluginReports: PluginReportJson[] = []; - let title: string | undefined; const stacks = await assembly.selectStacksV2(selectStacks); - const selectedStackIds = new Set(stacks.hierarchicalIds); - - // 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; - - // Filter the report to only include violations for the selected stacks - const filteredReports = filterReportsByStacks(report.pluginReports, selectedStackIds); - pluginReports.push(...filteredReports); - } 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.')); - } + + const reports = await obtainUnifiedValidationReport(assembly, stacks); // Online validation: submit templates to CloudFormation for early validation if (options.online ?? true) { @@ -693,25 +682,23 @@ export class Toolkit extends CloudAssemblySourceBuilder { const onlineReport = await this.validateOnline(ioHelper, stacks, deployments); if (onlineReport) { - pluginReports.push(onlineReport); + reports.push(onlineReport); } } - 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 hasAnyViolations = reports.some(report => report.violations && report.violations.length > 0); const result: ValidateResult = { - conclusion, - title, - pluginReports, + conclusion: combineConclusions(reports), + title: undefined, + pluginReports: reports, }; - await ioHelper.notify(hostMessageFromValidation(result)); + if (!hasAnyViolations) { + await ioHelper.notify(IO.CDK_TOOLKIT_I9600.msg('Validation did not find any problems.', result)); + } else { + await ioHelper.notify(hostMessageFromValidation(process.cwd(), result)); + } return result; } @@ -786,7 +773,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { const ioHelper = asIoHelper(this.ioHost, action); const selectStacks = stacksOpt(options); const stackCollection = await assembly.selectStacksV2(selectStacks); - await this.validateStacksMetadata(stackCollection, ioHelper); + await throwIfValidationFailures(assembly, stackCollection, this.assemblyFailureAt, ioHelper); const ret: DeployResult = { stacks: [], @@ -1302,7 +1289,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { const ioHelper = asIoHelper(this.ioHost, action); const stacks = await assembly.selectStacksV2(selectStacks); - await this.validateStacksMetadata(stacks, ioHelper); + await throwIfValidationFailures(assembly, stacks, this.assemblyFailureAt, ioHelper); const ret: RollbackResult = { stacks: [], @@ -1669,26 +1656,6 @@ export class Toolkit extends CloudAssemblySourceBuilder { } } - /** - * Validate the stacks for errors and warnings according to the CLI's current settings - */ - private async validateStacksMetadata(stacks: StackCollection, ioHost: IoHelper) { - const builder = (level: IoMessageLevel) => { - switch (level) { - case 'error': - return IO.CDK_ASSEMBLY_E9999; - case 'warn': - return IO.CDK_ASSEMBLY_W9999; - default: - return IO.CDK_ASSEMBLY_I9999; - } - }; - await stacks.validateMetadata( - this.props.assemblyFailureAt, - async (level, msg) => ioHost.notify(builder(level).msg(`[${level} at ${msg.id}] ${msg.entry.data}`, msg)), - ); - } - /** * Create a deployments class */ @@ -1824,6 +1791,9 @@ export class Toolkit extends CloudAssemblySourceBuilder { /** * Centralize the default stack selection logic in a single place + * + * Defaults to all stacks in the assembly (including nested assemblies) if no explicit + * selector is given. */ function stacksOpt(o: { stacks?: StackSelector }): StackSelector { return o.stacks ?? ALL_STACKS; @@ -1854,28 +1824,3 @@ async function synthAndMeasure( function zeroTime(): ElapsedTime { return { asMs: 0, asSec: 0 }; } - -function filterReportsByStacks(reports: PluginReportJson[], selectedStackIds: Set): PluginReportJson[] { - return reports.map((report) => { - const filteredViolations = report.violations.filter((violation) => { - if (violation.violatingConstructs.length === 0) return true; - return violation.violatingConstructs.some((c) => - selectedStackIds.has(c.constructPath?.split('/')[0] ?? ''), - ); - }).map((violation) => { - if (violation.violatingConstructs.length === 0) return violation; - return { - ...violation, - violatingConstructs: violation.violatingConstructs.filter((c) => - selectedStackIds.has(c.constructPath?.split('/')[0] ?? ''), - ), - }; - }); - - return { - ...report, - violations: filteredViolations, - conclusion: filteredViolations.length > 0 ? report.conclusion : ('success' as const), - }; - }); -} diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts index 7130805a8..d4e0cf701 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts @@ -160,3 +160,5 @@ export interface FeatureFlag { readonly explanation?: string; readonly unconfiguredBehavesLike?: { v2?: any }; } + +export type MinimumSeverity = 'error' | 'warn' | 'none'; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/synth.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/synth.test.ts index d83ed6c76..5a82167ef 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/synth.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/synth.test.ts @@ -131,7 +131,7 @@ describe('synth', () => { }); // WHEN - await expect(toolkit.synth(cx)).rejects.toThrow(/Found errors/); + await expect(toolkit.synth(cx)).rejects.toThrow(/Synthesis finished with errors/); // There should not be a lock remaining in the given output directory const lock = new RWLock(synthDir.dir); 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 c94c5bc40..9f9a291e8 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -22,7 +22,6 @@ describe('validate', () => { const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('failure'); - expect(result.title).toBe('Validation Report'); expect(result.pluginReports).toHaveLength(2); expect(result.pluginReports[0].pluginName).toBe('TestPlugin'); expect(result.pluginReports[0].conclusion).toBe('failure'); @@ -41,22 +40,6 @@ describe('validate', () => { expect(result.pluginReports[0].violations).toHaveLength(0); }); - 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, { online: false }); - - expect(result.conclusion).toBe('success'); - expect(result.pluginReports).toHaveLength(0); - ioHost.expectMessage({ containing: 'No validation plugins configured', level: 'info' }); - }); - - test('emits info IO message on success', async () => { - const cx = await cdkOutFixture(toolkit, 'stack-with-passing-validation'); - await toolkit.validate(cx, { online: false }); - - ioHost.expectMessage({ containing: 'No problems found', level: 'info' }); - }); - test('can invoke without options', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); const result = await toolkit.validate(cx, { online: false }); @@ -116,12 +99,11 @@ describe('validate', () => { await toolkit.validate(cx, { online: false }); const msg = ioHost.messages.find( - (m) => m.code === 'CDK_TOOLKIT_I9600', + (m) => m.code === 'CDK_TOOLKIT_E9600', ); expect(msg).toBeDefined(); expect(msg!.data).toMatchObject({ conclusion: 'failure', - title: 'Validation Report', pluginReports: expect.arrayContaining([ expect.objectContaining({ pluginName: 'TestPlugin', diff --git a/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts index 19fbdfafd..41f047cf7 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts @@ -1,6 +1,6 @@ import * as chalk from 'chalk'; import type { ValidateResult } from '../../../lib/actions/validate'; -import { formatValidateResult } from '../../../lib/api/validate/validate-formatting'; +import { formatValidateResult as formatValidateResult_ } from '../../../lib/api/validate/validate-formatting'; // Disable chalk for predictable assertions — set level directly because // env vars may not take effect when chalk is already loaded by another test in the same worker. @@ -12,13 +12,6 @@ function makeResult(pluginReports: ValidateResult['pluginReports']): ValidateRes } describe('formatValidateResult', () => { - test('returns pass message when no violations', () => { - const result = makeResult([ - { pluginName: 'TestPlugin', conclusion: 'success', violations: [] }, - ]); - expect(formatValidateResult(result)).toContain('No problems found.'); - }); - test('sorts violations by severity (fatal > error > warning > info > custom)', () => { const result = makeResult([{ pluginName: 'TestPlugin', @@ -34,11 +27,13 @@ describe('formatValidateResult', () => { const output = formatValidateResult(result); const lines = output.split('\n\n').filter(l => l.trim()); - expect(lines[1]).toContain('fatal issue'); - expect(lines[2]).toContain('error issue'); - expect(lines[3]).toContain('warning issue'); - expect(lines[4]).toContain('info issue'); - expect(lines[5]).toContain('custom issue'); + expect(lines).toEqual([ + expect.stringContaining('fatal issue'), + expect.stringContaining('error issue'), + expect.stringContaining('warning issue'), + expect.stringContaining('info issue'), + expect.stringContaining('custom issue'), + ]); }); test('formats construct path with logical id', () => { @@ -147,7 +142,7 @@ describe('formatValidateResult', () => { }]); const output = formatValidateResult(result); - expect(output).toContain("Acknowledge 'SecurityPlugin::no-public-buckets'"); + expect(output).toContain("Acknowledge with 'SecurityPlugin::no-public-buckets'"); }); test('includes constructFqn when present', () => { @@ -194,3 +189,7 @@ describe('formatValidateResult', () => { expect(output).toContain('Evil'); }); }); + +function formatValidateResult(result: ValidateResult) { + return formatValidateResult_(process.cwd(), result); +} diff --git a/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit.test.ts b/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit.test.ts index 76316b4a5..e932b33eb 100644 --- a/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit.test.ts @@ -90,7 +90,7 @@ test('outputs of assembly are measured', async () => { return app.synth(); }); - await expect(() => toolkit.synth(builder)).rejects.toThrow(/Found errors/); + await expect(() => toolkit.synth(builder)).rejects.toThrow(/Synthesis finished with errors/); expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ diff --git a/packages/aws-cdk/lib/api-private.ts b/packages/aws-cdk/lib/api-private.ts index 102510da0..c20411d15 100644 --- a/packages/aws-cdk/lib/api-private.ts +++ b/packages/aws-cdk/lib/api-private.ts @@ -8,3 +8,4 @@ export * from '../../@aws-cdk/toolkit-lib/lib/api/tags/private'; export * from '../../@aws-cdk/toolkit-lib/lib/private/activity-printer'; export * from '../../@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/borrowed-assembly'; export * from '../../@aws-cdk/toolkit-lib/lib/toolkit/private/count-assembly-results'; +export { throwIfValidationFailures } from '../../@aws-cdk/toolkit-lib/lib/toolkit/private/validation-report'; diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index e00ceb427..8f35662f3 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -14,7 +14,7 @@ import { CliIoHost } from './io-host'; import type { Configuration } from './user-configuration'; import { PROJECT_CONFIG } from './user-configuration'; import type { ActionLessRequest, IMessageSpan, IoHelper } from '../../lib/api-private'; -import { asIoHelper, cfnApi, createIgnoreMatcher, IO, tagsForStack } from '../../lib/api-private'; +import { asIoHelper, cfnApi, createIgnoreMatcher, IO, tagsForStack, throwIfValidationFailures } from '../../lib/api-private'; import type { AssetBuildNode, AssetPublishNode, Concurrency, MarkerNode, StackNode, WorkGraph, WorkGraphActions } from '../api'; import { buildDestroyWorkGraph, @@ -585,6 +585,9 @@ export class CdkToolkit { * Validate synthesized templates against policy rules */ public async validate(options: ValidateOptions): Promise { + // Implicitly switch 'debug' mode to true; more stack traces = more useful. + this.props.cloudExecutable.switchOnDebugging(); + const result = await this.toolkit.validate(this.props.cloudExecutable, options); return result.conclusion === 'failure' ? 1 : 0; } @@ -593,6 +596,9 @@ export class CdkToolkit { * Diagnose errors */ public async diagnose(options: DiagnoseOptions): Promise { + // Implicitly switch 'debug' mode to true; more stack traces = more useful. + this.props.cloudExecutable.switchOnDebugging(); + const results = await this.toolkit.diagnose(this.props.cloudExecutable, options); if (results.stacks.some(s => s.result.type !== 'no-problem')) { @@ -1270,7 +1276,7 @@ export class CdkToolkit { }); this.validateStacksSelected(stacks, selector.patterns); - await this.validateStacks(stacks); + await this.validateStacks(assembly, stacks); return stacks; } @@ -1296,7 +1302,7 @@ export class CdkToolkit { : new StackCollection(assembly, []); this.validateStacksSelected(selectedForDiff.concat(autoValidateStacks), stackNames); - await this.validateStacks(selectedForDiff.concat(autoValidateStacks)); + await this.validateStacks(assembly, selectedForDiff.concat(autoValidateStacks)); return selectedForDiff; } @@ -1316,9 +1322,9 @@ export class CdkToolkit { /** * Validate the stacks for errors and warnings according to the CLI's current settings */ - private async validateStacks(stacks: StackCollection) { + private async validateStacks(assembly: CloudAssembly, stacks: StackCollection) { const failAt = this.validateMetadataFailAt(); - await stacks.validateMetadata(failAt, stackMetadataLogger(this.ioHost.asIoHelper(), this.props.verbose)); + await throwIfValidationFailures(assembly, stacks, failAt, this.ioHost.asIoHelper()); } private validateMetadataFailAt(): 'warn' | 'error' | 'none' { @@ -2089,31 +2095,6 @@ export async function displayFlagsMessage(ioHost: IoHelper, toolkit: InternalToo } } -/** - * Logger for processing stack metadata - */ -function stackMetadataLogger(ioHelper: IoHelper, verbose?: boolean): (level: 'info' | 'error' | 'warn', msg: cxapi.SynthesisMessage) => Promise { - const makeLogger = (level: string): [logger: (m: string) => void, prefix: string] => { - switch (level) { - case 'error': - return [(m) => ioHelper.defaults.error(m), 'Error']; - case 'warn': - return [(m) => ioHelper.defaults.warn(m), 'Warning']; - default: - return [(m) => ioHelper.defaults.info(m), 'Info']; - } - }; - - return async (level, msg) => { - const [logFn, prefix] = makeLogger(level); - await logFn(`[${prefix} at ${msg.id}] ${msg.entry.data}`); - - if (verbose && msg.entry.trace) { - logFn(` ${msg.entry.trace.join('\n ')}`); - } - }; -} - interface WorkGraphDeploymentActionsOptions { readonly roleArn?: string; readonly force?: boolean; diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 4db7aa019..6966f1420 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -463,11 +463,6 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise { - settings.set(['debug'], true); - }); - return cli.diagnose({ stacks: specificStacksOrAllRecursively(args.STACKS), concurrency: args.concurrency, diff --git a/packages/aws-cdk/lib/cxapp/cloud-executable.ts b/packages/aws-cdk/lib/cxapp/cloud-executable.ts index 489e10929..d748c53c6 100644 --- a/packages/aws-cdk/lib/cxapp/cloud-executable.ts +++ b/packages/aws-cdk/lib/cxapp/cloud-executable.ts @@ -65,6 +65,18 @@ export class CloudExecutable implements ICloudAssemblySource { return !!this.props.configuration.settings.get(['app']); } + /** + * Switch on debugging for the cloud executable. + * + * This will cause it to log more stack traces and other information that will make error + * reports more useful (at the cost of increased execution time). + */ + public switchOnDebugging() { + this.props.configuration.settings.temporarilyMutable((settings) => { + settings.set(['debug'], true); + }); + } + /** * Synthesize a set of stacks. * diff --git a/packages/aws-cdk/test/commands/diff.test.ts b/packages/aws-cdk/test/commands/diff.test.ts index 068e9bc90..68c0dca66 100644 --- a/packages/aws-cdk/test/commands/diff.test.ts +++ b/packages/aws-cdk/test/commands/diff.test.ts @@ -622,7 +622,7 @@ describe('non-nested stacks', () => { toolkit.diff({ stackNames: ['C'], }), - ).rejects.toThrow(/Found errors/); + ).rejects.toThrow(/Synthesis finished with errors/); }); test('when quiet mode is enabled, stacks with no diffs should not print stack name & no differences to stdout', async () => { diff --git a/yarn.lock b/yarn.lock index c59aa11e5..68b6fbaa3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -223,18 +223,6 @@ __metadata: languageName: unknown linkType: soft -"@aws-cdk/cloud-assembly-api@npm:^2.2.2": - version: 2.2.2 - resolution: "@aws-cdk/cloud-assembly-api@npm:2.2.2" - dependencies: - jsonschema: "npm:~1.4.1" - semver: "npm:^7.7.4" - peerDependencies: - "@aws-cdk/cloud-assembly-schema": ">=53.15.0" - checksum: 10c0/cc5e7edd13d9d7e4ad7878c16f85a1a15c68e9ec51b71ad4a4c884ef0b3ed28fc25dc0627f0c226d65615cc6011ca02de94401862b650a996f8ccdff651e4d44 - languageName: node - linkType: hard - "@aws-cdk/cloud-assembly-api@npm:^2.2.5": version: 2.2.5 resolution: "@aws-cdk/cloud-assembly-api@npm:2.2.5" @@ -339,14 +327,14 @@ __metadata: linkType: soft "@aws-cdk/cx-api@npm:^2": - version: 2.251.0 - resolution: "@aws-cdk/cx-api@npm:2.251.0" + version: 2.260.0 + resolution: "@aws-cdk/cx-api@npm:2.260.0" dependencies: - "@aws-cdk/cloud-assembly-api": "npm:^2.2.2" - semver: "npm:^7.7.4" + "@aws-cdk/cloud-assembly-api": "npm:^2.2.5" + semver: "npm:^7.8.1" peerDependencies: - "@aws-cdk/cloud-assembly-schema": ">=53.18.0" - checksum: 10c0/480af47d19353bb6f5d52e1ea1e8f6179fed7039ff614f05a6c4f57668c054a00332cb5d02ecf39f7847140f41ecae69db594c4afce9749949f9fafdd79684fa + "@aws-cdk/cloud-assembly-schema": ">=53.25.0" + checksum: 10c0/cd4ebf070a8ce066c030fcc526074911c1b29494280cc7c4e3c19640c8d168e2a553f9c3cd2d5c7d03b611bfad7c3b920418aeb6f51b869208360e4a3119d383 languageName: node linkType: hard @@ -13633,13 +13621,6 @@ __metadata: languageName: node linkType: hard -"jsonschema@npm:~1.4.1": - version: 1.4.1 - resolution: "jsonschema@npm:1.4.1" - checksum: 10c0/c3422d3fc7d33ff7234a806ffa909bb6fb5d1cd664bea229c64a1785dc04cbccd5fc76cf547c6ab6dd7881dbcaf3540a6a9f925a5956c61a9cd3e23a3c1796ef - languageName: node - linkType: hard - "jszip@npm:^3.10.1": version: 3.10.1 resolution: "jszip@npm:3.10.1"