Skip to content

Commit 5c07d8c

Browse files
committed
WIP
1 parent 1b0086d commit 5c07d8c

File tree

7 files changed

+180
-5
lines changed

7 files changed

+180
-5
lines changed

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ import type {
100100
DescribeStackResourceDriftsCommandInput,
101101
ExecuteStackRefactorCommandInput,
102102
DescribeStackRefactorCommandInput,
103-
CreateStackRefactorCommandOutput, ExecuteStackRefactorCommandOutput,
103+
CreateStackRefactorCommandOutput, ExecuteStackRefactorCommandOutput, DescribeEventsCommandOutput, DescribeEventsCommandInput,
104104
} from '@aws-sdk/client-cloudformation';
105105
import {
106106
paginateListStacks,
@@ -113,6 +113,7 @@ import {
113113
DeleteChangeSetCommand,
114114
DeleteGeneratedTemplateCommand,
115115
DeleteStackCommand,
116+
DescribeEventsCommand,
116117
DescribeChangeSetCommand,
117118
DescribeGeneratedTemplateCommand,
118119
DescribeResourceScanCommand,
@@ -434,6 +435,7 @@ export interface ICloudFormationClient {
434435
deleteChangeSet(input: DeleteChangeSetCommandInput): Promise<DeleteChangeSetCommandOutput>;
435436
deleteGeneratedTemplate(input: DeleteGeneratedTemplateCommandInput): Promise<DeleteGeneratedTemplateCommandOutput>;
436437
deleteStack(input: DeleteStackCommandInput): Promise<DeleteStackCommandOutput>;
438+
describeEvents(input: DescribeEventsCommandInput): Promise<DescribeEventsCommandOutput>;
437439
describeChangeSet(input: DescribeChangeSetCommandInput): Promise<DescribeChangeSetCommandOutput>;
438440
describeGeneratedTemplate(
439441
input: DescribeGeneratedTemplateCommandInput,
@@ -710,6 +712,8 @@ export class SDK {
710712
client.send(new DetectStackDriftCommand(input)),
711713
detectStackResourceDrift: (input: DetectStackResourceDriftCommandInput): Promise<DetectStackResourceDriftCommandOutput> =>
712714
client.send(new DetectStackResourceDriftCommand(input)),
715+
describeEvents: (input: DescribeEventsCommandInput): Promise<DescribeEventsCommandOutput> =>
716+
client.send(new DescribeEventsCommand(input)),
713717
describeChangeSet: (input: DescribeChangeSetCommandInput): Promise<DescribeChangeSetCommandOutput> =>
714718
client.send(new DescribeChangeSetCommand(input)),
715719
describeGeneratedTemplate: (

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

Lines changed: 7 additions & 1 deletion
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+
check(description: DescribeChangeSetCommandOutput, changeSetName: string, stackName: string): Promise<void>;
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,6 +125,8 @@ export async function waitForChangeSet(
121125
return description;
122126
}
123127

128+
await validationReporter?.check(description, changeSetName, stackName);
129+
124130
// eslint-disable-next-line @stylistic/max-len
125131
throw new ToolkitError(
126132
`Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${description.StatusReason || 'no reason provided'}`,

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,13 @@ import { formatErrorMessage } from '../../util';
3232
import type { SDK, SdkProvider, ICloudFormationClient } from '../aws-auth/private';
3333
import type { TemplateBodyParameter } from '../cloudformation';
3434
import { makeBodyParameter, CfnEvaluationException, CloudFormationStack } from '../cloudformation';
35-
import type { EnvironmentResources, StringWithoutPlaceholders } from '../environment';
35+
import { EnvironmentResources, EnvironmentResourcesRegistry, StringWithoutPlaceholders } from '../environment';
3636
import { HotswapPropertyOverrides, HotswapMode, ICON, createHotswapPropertyOverrides } from '../hotswap/common';
3737
import { tryHotswapDeployment } from '../hotswap/hotswap-deployments';
3838
import type { IoHelper } from '../io/private';
3939
import type { ResourcesToImport } from '../resource-import';
4040
import { StackActivityMonitor } from '../stack-events';
41+
import { EarlyValidationReporter } from './early-validation';
4142

4243
export interface DeployStackOptions {
4344
/**
@@ -511,8 +512,12 @@ class FullCloudFormationDeployment {
511512

512513
await this.ioHelper.defaults.debug(format('Initiated creation of changeset: %s; waiting for it to finish creating...', changeSet.Id));
513514
// Fetching all pages if we'll execute, so we can have the correct change count when monitoring.
515+
const environmentResources = new EnvironmentResourcesRegistry()
516+
.for(this.options.resolvedEnvironment, this.options.sdk, this.ioHelper);
517+
const validationReporter = new EarlyValidationReporter(this.options.sdk, environmentResources);
514518
return waitForChangeSet(this.cfn, this.ioHelper, this.stackName, changeSetName, {
515519
fetchAll: willExecute,
520+
validationReporter,
516521
});
517522
}
518523

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { DescribeChangeSetCommandOutput } from '@aws-sdk/client-cloudformation';
2+
import { ChangeSetStatus, ValidationStatus } from '@aws-sdk/client-cloudformation';
3+
import type { ValidationReporter } from './cfn-api';
4+
import { ToolkitError } from '../../toolkit/toolkit-error';
5+
import type { SDK } from '../aws-auth/sdk';
6+
import type { EnvironmentResources } from '../environment/index';
7+
8+
export class EarlyValidationReporter implements ValidationReporter {
9+
constructor(private readonly sdk: SDK, private readonly environmentResources: EnvironmentResources) {
10+
}
11+
12+
public async check(description: DescribeChangeSetCommandOutput, changeSetName: string, stackName: string) {
13+
if (description.Status === ChangeSetStatus.FAILED && description.StatusReason?.includes('AWS::EarlyValidation')) {
14+
await this.checkBootstrapVersion();
15+
const eventsOutput = await this.sdk.cloudFormation().describeEvents({
16+
ChangeSetName: changeSetName,
17+
StackName: stackName,
18+
});
19+
20+
const failures = (eventsOutput.OperationEvents ?? [])
21+
.filter((event) => event.ValidationStatus === ValidationStatus.FAILED)
22+
.map((event) => ` - ${event.ValidationStatusReason} (at ${event.ValidationPath})`)
23+
.join('\n');
24+
25+
const message = `ChangeSet '${changeSetName}' on stack '${stackName}' failed early validation:\n${failures}`;
26+
throw new ToolkitError(message);
27+
}
28+
}
29+
30+
private async checkBootstrapVersion() {
31+
const environment = this.environmentResources.environment;
32+
let bootstrapVersion: number | undefined = undefined;
33+
try {
34+
// Try to get the bootstrap version
35+
bootstrapVersion = (await this.environmentResources.lookupToolkit()).version;
36+
} catch (e) {
37+
// But if we can't, keep going. Maybe we can still succeed.
38+
}
39+
if (bootstrapVersion != null && bootstrapVersion < 30) {
40+
const env = `aws://${environment.account}/${environment.region}`;
41+
throw new ToolkitError(
42+
'While creating the change set, CloudFormation detected errors in the generated templates.\n' +
43+
`To see details about these errors, re-bootstrap your environment with 'cdk bootstrap ${env}', and run 'cdk deploy' again.`,
44+
);
45+
}
46+
}
47+
}

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

Lines changed: 25 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,30 @@ 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(`While creating the change set, CloudFormation detected errors in the generated templates.
788+
To see details about these errors, re-bootstrap your environment with 'cdk bootstrap aws://123456789/bermuda-triangle-1337', and run 'cdk deploy' again.`);
789+
});
790+
766791
test('deploy not skipped if template did not change but one tag removed', async () => {
767792
// GIVEN
768793
mockCloudFormationClient.on(DescribeStacksCommand).resolves({
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { ChangeSetStatus } from '@aws-sdk/client-cloudformation';
2+
import { EarlyValidationReporter } from '../../../lib/api/deployments/early-validation';
3+
4+
describe('EarlyValidationReporter', () => {
5+
let mockSdk: any;
6+
let mockEnvironmentResources: any;
7+
let reporter: EarlyValidationReporter;
8+
9+
beforeEach(() => {
10+
mockSdk = {
11+
cloudFormation: jest.fn().mockReturnValue({
12+
describeEvents: jest.fn(),
13+
}),
14+
};
15+
mockEnvironmentResources = {
16+
environment: { account: '123456789012', region: 'us-east-1' },
17+
lookupToolkit: jest.fn(),
18+
};
19+
reporter = new EarlyValidationReporter(mockSdk, mockEnvironmentResources);
20+
});
21+
22+
it('does not throw when ChangeSet status is FAILED but reason is not AWS::EarlyValidation', async () => {
23+
const description = {
24+
$metadata: {},
25+
Status: ChangeSetStatus.FAILED,
26+
StatusReason: 'Some other reason',
27+
};
28+
const changeSetName = 'test-change-set';
29+
const stackName = 'test-stack';
30+
31+
await expect(reporter.check(description, changeSetName, stackName)).resolves.not.toThrow();
32+
});
33+
34+
it('does not throw when ChangeSet status is undefined', async () => {
35+
const description = {
36+
$metadata: {},
37+
Status: undefined,
38+
StatusReason: undefined,
39+
};
40+
const changeSetName = 'test-change-set';
41+
const stackName = 'test-stack';
42+
43+
await expect(reporter.check(description, changeSetName, stackName)).resolves.not.toThrow();
44+
});
45+
46+
it('throws when ChangeSet status is FAILED due to AWS::EarlyValidation', async () => {
47+
const description = {
48+
$metadata: {},
49+
Status: ChangeSetStatus.FAILED,
50+
StatusReason: 'The following resource(s) failed to create: [MyResource] (AWS::EarlyValidation).',
51+
};
52+
const changeSetName = 'test-change-set';
53+
const stackName = 'test-stack';
54+
55+
mockSdk.cloudFormation().describeEvents.mockResolvedValue({
56+
OperationEvents: [
57+
{
58+
ValidationStatus: 'FAILED',
59+
ValidationStatusReason: 'Resource already exists',
60+
ValidationPath: 'Resources/MyResource',
61+
},
62+
],
63+
});
64+
65+
await expect(reporter.check(description, changeSetName, stackName)).rejects.toThrow(
66+
`ChangeSet 'test-change-set' on stack 'test-stack' failed early validation:
67+
- Resource already exists (at Resources/MyResource)`,
68+
);
69+
});
70+
71+
it('throws with bootstrap version less than 30', async () => {
72+
const description = {
73+
$metadata: {},
74+
Status: ChangeSetStatus.FAILED,
75+
StatusReason: 'The following resource(s) failed to create: [MyResource] (AWS::EarlyValidation).',
76+
};
77+
const changeSetName = 'test-change-set';
78+
const stackName = 'test-stack';
79+
80+
mockEnvironmentResources.lookupToolkit.mockResolvedValue({ version: 29 });
81+
82+
await expect(reporter.check(description, changeSetName, stackName)).rejects.toThrow(
83+
`While creating the change set, CloudFormation detected errors in the generated templates.
84+
To see details about these errors, re-bootstrap your environment with 'cdk bootstrap aws://123456789012/us-east-1', and run 'cdk deploy' again.`,
85+
);
86+
});
87+
});

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)