Skip to content

Commit 3c765cd

Browse files
authored
Merge branch 'main' into feat(init)/support-package-manager-option
2 parents e251095 + 3ca8b70 commit 3c765cd

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)