Skip to content

Commit 3ca8b70

Browse files
authored
feat: show early validation errors on deploy (#970)
CloudFormation has [recently launched early validation][1]. When creating the change set, CloudFormation validates a few things such as whether the deploy would try to create resources with existing physical IDs. More validations to come. But the specific information about the error (other than it's an early validation error) is not returned by the `DescribeChangeSet` API. The consumer is supposed to call a new API, `DescribeEvents` to get that information. Introduce a new class,`EarlyValidationReporter` that checks whether such an error occurred and call the `DescribeEvents` API. The bootstrap stack was also updated to include permission to this new API. If the role being used for deployment doesn't have permission to call `DescribeEvents`, the toolkit will warn the user that it cannot show the proper error message, and suggest a re-bootstrap. [1]: https://aws.amazon.com/about-aws/whats-new/2025/11/cloudformation-dev-test-cycle-validation-troubleshooting/ --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 2a6f8d3 commit 3ca8b70

File tree

9 files changed

+234
-5
lines changed

9 files changed

+234
-5
lines changed

packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,17 @@ class DriftableStack extends cdk.Stack {
502502
}
503503
}
504504

505+
class EarlyValidationStack extends cdk.Stack {
506+
constructor(parent, id, props) {
507+
super(parent, id, props);
508+
509+
new s3.Bucket(this, 'MyBucket', {
510+
bucketName: process.env.BUCKET_NAME,
511+
removalPolicy: cdk.RemovalPolicy.DESTROY,
512+
});
513+
}
514+
}
515+
505516
class IamRolesStack extends cdk.Stack {
506517
constructor(parent, id, props) {
507518
super(parent, id, props);
@@ -971,6 +982,9 @@ switch (stackSet) {
971982
new MetadataStack(app, `${stackPrefix}-metadata`);
972983

973984
new DriftableStack(app, `${stackPrefix}-driftable`);
985+
986+
new EarlyValidationStack(app, `${stackPrefix}-early-validation-stack1`);
987+
new EarlyValidationStack(app, `${stackPrefix}-early-validation-stack2`);
974988
break;
975989

976990
case 'stage-using-context':
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { randomUUID } from 'node:crypto';
2+
import { integTest, withDefaultFixture } from '../../../lib';
3+
4+
jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime
5+
6+
integTest(
7+
'deploy - early validation error',
8+
withDefaultFixture(async (fixture) => {
9+
const bucketName = randomUUID();
10+
11+
// First, deploy a stack that creates a bucket with a custom name, which we expect to succeed
12+
await fixture.cdkDeploy('early-validation-stack1', {
13+
modEnv: {
14+
BUCKET_NAME: bucketName,
15+
},
16+
});
17+
18+
// Then deploy a different instance of the stack, that creates another
19+
// bucket with the same name, to induce an early validation error
20+
const stdErr = await fixture.cdkDeploy('early-validation-stack2', {
21+
modEnv: {
22+
BUCKET_NAME: bucketName,
23+
},
24+
allowErrExit: true,
25+
});
26+
27+
expect(stdErr).toContain(`Resource of type 'AWS::S3::Bucket' with identifier '${bucketName}' already exists`,
28+
);
29+
}),
30+
);
31+

packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,14 @@ import type {
100100
DescribeStackResourceDriftsCommandInput,
101101
ExecuteStackRefactorCommandInput,
102102
DescribeStackRefactorCommandInput,
103-
CreateStackRefactorCommandOutput, ExecuteStackRefactorCommandOutput,
103+
CreateStackRefactorCommandOutput,
104+
ExecuteStackRefactorCommandOutput,
105+
DescribeEventsCommandOutput,
106+
DescribeEventsCommandInput,
104107
} from '@aws-sdk/client-cloudformation';
105108
import {
109+
paginateDescribeEvents,
110+
106111
paginateListStacks,
107112
CloudFormationClient,
108113
ContinueUpdateRollbackCommand,
@@ -113,6 +118,7 @@ import {
113118
DeleteChangeSetCommand,
114119
DeleteGeneratedTemplateCommand,
115120
DeleteStackCommand,
121+
DescribeEventsCommand,
116122
DescribeChangeSetCommand,
117123
DescribeGeneratedTemplateCommand,
118124
DescribeResourceScanCommand,
@@ -141,6 +147,7 @@ import {
141147
waitUntilStackRefactorCreateComplete,
142148
waitUntilStackRefactorExecuteComplete,
143149
} from '@aws-sdk/client-cloudformation';
150+
import type { OperationEvent } from '@aws-sdk/client-cloudformation/dist-types/models/models_0';
144151
import type {
145152
FilterLogEventsCommandInput,
146153
FilterLogEventsCommandOutput,
@@ -434,6 +441,7 @@ export interface ICloudFormationClient {
434441
deleteChangeSet(input: DeleteChangeSetCommandInput): Promise<DeleteChangeSetCommandOutput>;
435442
deleteGeneratedTemplate(input: DeleteGeneratedTemplateCommandInput): Promise<DeleteGeneratedTemplateCommandOutput>;
436443
deleteStack(input: DeleteStackCommandInput): Promise<DeleteStackCommandOutput>;
444+
describeEvents(input: DescribeEventsCommandInput): Promise<DescribeEventsCommandOutput>;
437445
describeChangeSet(input: DescribeChangeSetCommandInput): Promise<DescribeChangeSetCommandOutput>;
438446
describeGeneratedTemplate(
439447
input: DescribeGeneratedTemplateCommandInput,
@@ -468,6 +476,7 @@ export interface ICloudFormationClient {
468476
describeStackEvents(input: DescribeStackEventsCommandInput): Promise<DescribeStackEventsCommandOutput>;
469477
listStackResources(input: ListStackResourcesCommandInput): Promise<StackResourceSummary[]>;
470478
paginatedListStacks(input: ListStacksCommandInput): Promise<StackSummary[]>;
479+
paginatedDescribeEvents(input: DescribeEventsCommandInput): Promise<OperationEvent[]>;
471480
createStackRefactor(input: CreateStackRefactorCommandInput): Promise<CreateStackRefactorCommandOutput>;
472481
executeStackRefactor(input: ExecuteStackRefactorCommandInput): Promise<ExecuteStackRefactorCommandOutput>;
473482
waitUntilStackRefactorCreateComplete(input: DescribeStackRefactorCommandInput): Promise<WaiterResult>;
@@ -710,6 +719,8 @@ export class SDK {
710719
client.send(new DetectStackDriftCommand(input)),
711720
detectStackResourceDrift: (input: DetectStackResourceDriftCommandInput): Promise<DetectStackResourceDriftCommandOutput> =>
712721
client.send(new DetectStackResourceDriftCommand(input)),
722+
describeEvents: (input: DescribeEventsCommandInput): Promise<DescribeEventsCommandOutput> =>
723+
client.send(new DescribeEventsCommand(input)),
713724
describeChangeSet: (input: DescribeChangeSetCommandInput): Promise<DescribeChangeSetCommandOutput> =>
714725
client.send(new DescribeChangeSetCommand(input)),
715726
describeGeneratedTemplate: (
@@ -775,6 +786,14 @@ export class SDK {
775786
}
776787
return stackResources;
777788
},
789+
paginatedDescribeEvents: async (input: DescribeEventsCommandInput): Promise<OperationEvent[]> => {
790+
const stackResources = Array<OperationEvent>();
791+
const paginator = paginateDescribeEvents({ client }, input);
792+
for await (const page of paginator) {
793+
stackResources.push(...(page.OperationEvents || []));
794+
}
795+
return stackResources;
796+
},
778797
createStackRefactor: (input: CreateStackRefactorCommandInput): Promise<CreateStackRefactorCommandOutput> => {
779798
return client.send(new CreateStackRefactorCommand(input));
780799
},

packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import { CloudFormationStack, makeBodyParameter } from '../cloudformation';
2121
import type { IoHelper } from '../io/private';
2222
import type { ResourcesToImport } from '../resource-import';
2323

24+
export interface ValidationReporter {
25+
fetchDetails(changeSetName: string, stackName: string): Promise<string>;
26+
}
27+
2428
/**
2529
* Describe a changeset in CloudFormation, regardless of its current state.
2630
*
@@ -103,7 +107,7 @@ export async function waitForChangeSet(
103107
ioHelper: IoHelper,
104108
stackName: string,
105109
changeSetName: string,
106-
{ fetchAll }: { fetchAll: boolean },
110+
{ fetchAll, validationReporter }: { fetchAll: boolean; validationReporter?: ValidationReporter },
107111
): Promise<DescribeChangeSetCommandOutput> {
108112
await ioHelper.defaults.debug(format('Waiting for changeset %s on stack %s to finish creating...', changeSetName, stackName));
109113
const ret = await waitFor(async () => {
@@ -121,9 +125,20 @@ export async function waitForChangeSet(
121125
return description;
122126
}
123127

128+
const isEarlyValidationError = description.Status === ChangeSetStatus.FAILED &&
129+
description.StatusReason?.includes('AWS::EarlyValidation');
130+
131+
if (isEarlyValidationError) {
132+
const details = await validationReporter?.fetchDetails(changeSetName, stackName);
133+
if (details) {
134+
throw new ToolkitError(details);
135+
}
136+
}
124137
// eslint-disable-next-line @stylistic/max-len
125138
throw new ToolkitError(
126-
`Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${description.StatusReason || 'no reason provided'}`,
139+
`Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${
140+
description.StatusReason || 'no reason provided'
141+
}`,
127142
);
128143
});
129144

packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ import type { SDK, SdkProvider, ICloudFormationClient } from '../aws-auth/privat
3333
import type { TemplateBodyParameter } from '../cloudformation';
3434
import { makeBodyParameter, CfnEvaluationException, CloudFormationStack } from '../cloudformation';
3535
import type { EnvironmentResources, StringWithoutPlaceholders } from '../environment';
36+
import { EnvironmentResourcesRegistry } from '../environment';
3637
import { HotswapPropertyOverrides, HotswapMode, ICON, createHotswapPropertyOverrides } from '../hotswap/common';
3738
import { tryHotswapDeployment } from '../hotswap/hotswap-deployments';
3839
import type { IoHelper } from '../io/private';
3940
import type { ResourcesToImport } from '../resource-import';
4041
import { StackActivityMonitor } from '../stack-events';
42+
import { EarlyValidationReporter } from './early-validation';
4143

4244
export interface DeployStackOptions {
4345
/**
@@ -511,8 +513,12 @@ class FullCloudFormationDeployment {
511513

512514
await this.ioHelper.defaults.debug(format('Initiated creation of changeset: %s; waiting for it to finish creating...', changeSet.Id));
513515
// Fetching all pages if we'll execute, so we can have the correct change count when monitoring.
516+
const environmentResourcesRegistry = new EnvironmentResourcesRegistry();
517+
const envResources = environmentResourcesRegistry.for(this.options.resolvedEnvironment, this.options.sdk, this.ioHelper);
518+
const validationReporter = new EarlyValidationReporter(this.options.sdk, envResources);
514519
return waitForChangeSet(this.cfn, this.ioHelper, this.stackName, changeSetName, {
515520
fetchAll: willExecute,
521+
validationReporter,
516522
});
517523
}
518524

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { OperationEvent } from '@aws-sdk/client-cloudformation';
2+
import type { ValidationReporter } from './cfn-api';
3+
import type { SDK } from '../aws-auth/sdk';
4+
import type { EnvironmentResources } from '../environment';
5+
6+
/**
7+
* A ValidationReporter that checks for early validation errors right after
8+
* creating the change set. If any are found, it throws an error listing all validation failures.
9+
* If the DescribeEvents API call fails (for example, due to insufficient permissions),
10+
* it logs a warning instead.
11+
*/
12+
export class EarlyValidationReporter implements ValidationReporter {
13+
constructor(private readonly sdk: SDK, private readonly envResources: EnvironmentResources) {
14+
}
15+
16+
public async fetchDetails(changeSetName: string, stackName: string): Promise<string> {
17+
let operationEvents: OperationEvent[] = [];
18+
try {
19+
operationEvents = await this.getFailedEvents(stackName, changeSetName);
20+
} catch (error) {
21+
let currentVersion: number | undefined = undefined;
22+
try {
23+
currentVersion = (await this.envResources.lookupToolkit()).version;
24+
} catch (e) {
25+
}
26+
27+
return `The template cannot be deployed because of early validation errors, but retrieving more details about those
28+
errors failed (${error}). Make sure you have permissions to call the DescribeEvents API, or re-bootstrap
29+
your environment with the latest version of the CLI (need at least version 30, current version ${currentVersion ?? 'unknown'}).`;
30+
}
31+
32+
let message = `ChangeSet '${changeSetName}' on stack '${stackName}' failed early validation`;
33+
if (operationEvents.length > 0) {
34+
const failures = operationEvents
35+
.map((event) => ` - ${event.ValidationStatusReason} (at ${event.ValidationPath})`)
36+
.join('\n');
37+
38+
message += `:\n${failures}\n`;
39+
}
40+
return message;
41+
}
42+
43+
private async getFailedEvents(stackName: string, changeSetName: string) {
44+
return this.sdk.cloudFormation().paginatedDescribeEvents({
45+
StackName: stackName,
46+
ChangeSetName: changeSetName,
47+
Filters: {
48+
FailedEvents: true,
49+
},
50+
});
51+
}
52+
}

packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DeleteChangeSetCommand,
88
DeleteStackCommand,
99
DescribeChangeSetCommand,
10+
DescribeEventsCommand,
1011
DescribeStacksCommand,
1112
ExecuteChangeSetCommand,
1213
type ExecuteChangeSetCommandInput,
@@ -763,6 +764,49 @@ test('deployStack reports no change if describeChangeSet returns specific error'
763764
expect(deployResult.type === 'did-deploy-stack' && deployResult.noOp).toEqual(true);
764765
});
765766

767+
test('deployStack throws error in case of early validation failures', async () => {
768+
mockCloudFormationClient.on(DescribeChangeSetCommand).resolvesOnce({
769+
Status: ChangeSetStatus.FAILED,
770+
StatusReason: '(AWS::EarlyValidation::SomeError). Blah blah blah.',
771+
});
772+
773+
mockCloudFormationClient.on(DescribeEventsCommand).resolves({
774+
OperationEvents: [
775+
{
776+
ValidationStatus: 'FAILED',
777+
ValidationStatusReason: 'Resource already exists',
778+
ValidationPath: 'Resources/MyResource',
779+
},
780+
],
781+
});
782+
783+
await expect(
784+
testDeployStack({
785+
...standardDeployStackArguments(),
786+
}),
787+
).rejects.toThrow(`ChangeSet 'cdk-deploy-change-set' on stack 'withouterrors' failed early validation:
788+
- Resource already exists (at Resources/MyResource)`);
789+
});
790+
791+
test('deployStack warns when it cannot get the events in case of early validation errors', async () => {
792+
mockCloudFormationClient.on(DescribeChangeSetCommand).resolvesOnce({
793+
Status: ChangeSetStatus.FAILED,
794+
StatusReason: '(AWS::EarlyValidation::SomeError). Blah blah blah.',
795+
});
796+
797+
mockCloudFormationClient.on(DescribeEventsCommand).rejectsOnce({
798+
message: 'AccessDenied',
799+
});
800+
801+
await expect(
802+
testDeployStack({
803+
...standardDeployStackArguments(),
804+
}),
805+
).rejects.toThrow(`The template cannot be deployed because of early validation errors, but retrieving more details about those
806+
errors failed (Error: AccessDenied). Make sure you have permissions to call the DescribeEvents API, or re-bootstrap
807+
your environment with the latest version of the CLI (need at least version 30, current version 0).`);
808+
});
809+
766810
test('deploy not skipped if template did not change but one tag removed', async () => {
767811
// GIVEN
768812
mockCloudFormationClient.on(DescribeStacksCommand).resolves({
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { EarlyValidationReporter } from '../../../lib/api/deployments/early-validation';
2+
3+
it('returns details when there are failed validation events', async () => {
4+
const sdkMock = {
5+
cloudFormation: jest.fn().mockReturnValue({
6+
paginatedDescribeEvents: jest.fn().mockResolvedValue([
7+
{ ValidationStatusReason: 'Resource already exists', ValidationPath: 'Resources/MyResource' },
8+
]),
9+
}),
10+
};
11+
const envResourcesMock = { lookupToolkit: jest.fn().mockResolvedValue({ version: 30 }) };
12+
const reporter = new EarlyValidationReporter(sdkMock as any, envResourcesMock as any);
13+
14+
await expect(reporter.fetchDetails('test-change-set', 'test-stack')).resolves.toEqual(
15+
"ChangeSet 'test-change-set' on stack 'test-stack' failed early validation:\n - Resource already exists (at Resources/MyResource)\n",
16+
);
17+
});
18+
19+
it('returns a summary when there are no failed validation events', async () => {
20+
const sdkMock = {
21+
cloudFormation: jest.fn().mockReturnValue({
22+
paginatedDescribeEvents: jest.fn().mockResolvedValue([]),
23+
}),
24+
};
25+
const envResourcesMock = { lookupToolkit: jest.fn().mockResolvedValue({ version: 30 }) };
26+
const reporter = new EarlyValidationReporter(sdkMock as any, envResourcesMock as any);
27+
28+
await expect(reporter.fetchDetails('test-change-set', 'test-stack')).resolves.toEqual(
29+
"ChangeSet 'test-change-set' on stack 'test-stack' failed early validation",
30+
);
31+
});
32+
33+
it('returns an explanatory message when DescribeEvents API call fails', async () => {
34+
const sdkMock = {
35+
cloudFormation: jest.fn().mockReturnValue({
36+
paginatedDescribeEvents: jest.fn().mockRejectedValue(new Error('AccessDenied')),
37+
}),
38+
};
39+
const envResourcesMock = { lookupToolkit: jest.fn().mockResolvedValue({ version: 29 }) };
40+
const reporter = new EarlyValidationReporter(sdkMock as any, envResourcesMock as any);
41+
42+
const result = await reporter.fetchDetails('test-change-set', 'test-stack');
43+
44+
expect(result).toContain('The template cannot be deployed because of early validation errors');
45+
expect(result).toContain('AccessDenied');
46+
expect(result).toContain('29');
47+
});

packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,7 @@ Resources:
631631
- cloudformation:DeleteChangeSet
632632
- cloudformation:DescribeChangeSet
633633
- cloudformation:DescribeStacks
634+
- cloudformation:DescribeEvents
634635
- cloudformation:ExecuteChangeSet
635636
- cloudformation:CreateStack
636637
- cloudformation:UpdateStack
@@ -814,7 +815,7 @@ Resources:
814815
Name:
815816
Fn::Sub: '/cdk-bootstrap/${Qualifier}/version'
816817
# Also update this value below (see comment there)
817-
Value: '29'
818+
Value: '30'
818819
Outputs:
819820
BucketName:
820821
Description: The name of the S3 bucket owned by the CDK toolkit stack
@@ -849,4 +850,4 @@ Outputs:
849850
# {Fn::GetAtt} on an SSM Parameter is eventually consistent, and can fail with "parameter
850851
# doesn't exist" even after just having been created. To reduce our deploy failure rate, we
851852
# duplicate the value here and use a build-time test to ensure the two values are the same.
852-
Value: '29'
853+
Value: '30'

0 commit comments

Comments
 (0)