Skip to content

Commit dd12d3b

Browse files
committed
Warning in case the events cannot be fetched
1 parent 465d44b commit dd12d3b

File tree

6 files changed

+121
-134
lines changed

6 files changed

+121
-134
lines changed

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

Lines changed: 16 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, DescribeEventsCommandOutput, DescribeEventsCommandInput,
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,
@@ -142,6 +147,7 @@ import {
142147
waitUntilStackRefactorCreateComplete,
143148
waitUntilStackRefactorExecuteComplete,
144149
} from '@aws-sdk/client-cloudformation';
150+
import type { OperationEvent } from '@aws-sdk/client-cloudformation/dist-types/models/models_0';
145151
import type {
146152
FilterLogEventsCommandInput,
147153
FilterLogEventsCommandOutput,
@@ -470,6 +476,7 @@ export interface ICloudFormationClient {
470476
describeStackEvents(input: DescribeStackEventsCommandInput): Promise<DescribeStackEventsCommandOutput>;
471477
listStackResources(input: ListStackResourcesCommandInput): Promise<StackResourceSummary[]>;
472478
paginatedListStacks(input: ListStacksCommandInput): Promise<StackSummary[]>;
479+
paginatedDescribeEvents(input: DescribeEventsCommandInput): Promise<OperationEvent[]>;
473480
createStackRefactor(input: CreateStackRefactorCommandInput): Promise<CreateStackRefactorCommandOutput>;
474481
executeStackRefactor(input: ExecuteStackRefactorCommandInput): Promise<ExecuteStackRefactorCommandOutput>;
475482
waitUntilStackRefactorCreateComplete(input: DescribeStackRefactorCommandInput): Promise<WaiterResult>;
@@ -779,6 +786,14 @@ export class SDK {
779786
}
780787
return stackResources;
781788
},
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+
},
782797
createStackRefactor: (input: CreateStackRefactorCommandInput): Promise<CreateStackRefactorCommandOutput> => {
783798
return client.send(new CreateStackRefactorCommand(input));
784799
},

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { IoHelper } from '../io/private';
2222
import type { ResourcesToImport } from '../resource-import';
2323

2424
export interface ValidationReporter {
25-
check(description: DescribeChangeSetCommandOutput, changeSetName: string, stackName: string): Promise<void>;
25+
report(changeSetName: string, stackName: string): Promise<void>;
2626
}
2727

2828
/**
@@ -125,12 +125,17 @@ export async function waitForChangeSet(
125125
return description;
126126
}
127127

128-
await validationReporter?.check(description, changeSetName, stackName);
129-
130-
// eslint-disable-next-line @stylistic/max-len
131-
throw new ToolkitError(
132-
`Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${description.StatusReason || 'no reason provided'}`,
133-
);
128+
if (description.Status === ChangeSetStatus.FAILED && description.StatusReason?.includes('AWS::EarlyValidation')) {
129+
await validationReporter?.report(changeSetName, stackName);
130+
return description;
131+
} else {
132+
// eslint-disable-next-line @stylistic/max-len
133+
throw new ToolkitError(
134+
`Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${
135+
description.StatusReason || 'no reason provided'
136+
}`,
137+
);
138+
}
134139
});
135140

136141
if (!ret) {

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ 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 { EnvironmentResources, EnvironmentResourcesRegistry, StringWithoutPlaceholders } from '../environment';
35+
import type { EnvironmentResources, 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';
@@ -512,9 +512,7 @@ class FullCloudFormationDeployment {
512512

513513
await this.ioHelper.defaults.debug(format('Initiated creation of changeset: %s; waiting for it to finish creating...', changeSet.Id));
514514
// 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);
515+
const validationReporter = new EarlyValidationReporter(this.options.sdk, this.ioHelper);
518516
return waitForChangeSet(this.cfn, this.ioHelper, this.stackName, changeSetName, {
519517
fetchAll: willExecute,
520518
validationReporter,
Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,34 @@
1-
import type { DescribeChangeSetCommandOutput } from '@aws-sdk/client-cloudformation';
2-
import { ChangeSetStatus, ValidationStatus } from '@aws-sdk/client-cloudformation';
1+
import type { OperationEvent } from '@aws-sdk/client-cloudformation';
32
import type { ValidationReporter } from './cfn-api';
43
import { ToolkitError } from '../../toolkit/toolkit-error';
54
import type { SDK } from '../aws-auth/sdk';
6-
import type { EnvironmentResources } from '../environment/index';
5+
import type { IoHelper } from '../io/private';
76

87
/**
9-
* Reports early validation failures from CloudFormation ChangeSets. To determine what the actual error is
10-
* it needs to call the `DescribeEvents` API, and therefore the deployment role in the bootstrap bucket
11-
* must have permissions to call that API. This permission was introduced in version 30 of the bootstrap template.
12-
* If the bootstrap stack is older than that, a special error message is thrown to instruct the user to
13-
* re-bootstrap.
8+
* A ValidationReporter that checks for early validation errors right after
9+
* creating the change set. If any are found, it throws an error listing all validation failures.
10+
* If the DescribeEvents API call fails (for example, due to insufficient permissions),
11+
* it logs a warning instead.
1412
*/
1513
export class EarlyValidationReporter implements ValidationReporter {
16-
constructor(private readonly sdk: SDK, private readonly environmentResources: EnvironmentResources) {
14+
constructor(private readonly sdk: SDK, private readonly ioHelper: IoHelper) {
1715
}
1816

19-
/**
20-
* Checks whether the ChangeSet failed early validation, and if so, throw an error. Otherwise, do nothing.
21-
* @param description - the ChangeSet description, resulting from the call to CloudFormation's DescribeChangeSet API
22-
* @param changeSetName - the name of the ChangeSet
23-
* @param stackName - the name of the stack
24-
*/
25-
public async check(description: DescribeChangeSetCommandOutput, changeSetName: string, stackName: string) {
26-
if (description.Status === ChangeSetStatus.FAILED && description.StatusReason?.includes('AWS::EarlyValidation')) {
27-
await this.checkBootstrapVersion();
28-
const eventsOutput = await this.sdk.cloudFormation().describeEvents({
29-
ChangeSetName: changeSetName,
30-
StackName: stackName,
31-
});
17+
public async report(changeSetName: string, stackName: string) {
18+
let operationEvents: OperationEvent[] = [];
19+
try {
20+
operationEvents = await this.getFailedEvents(stackName, changeSetName);
21+
} catch (error) {
22+
const message =
23+
'While creating the change set, CloudFormation detected errors in the generated templates,' +
24+
' but the deployment role does not have permissions to call the DescribeEvents API to retrieve details about these errors.\n' +
25+
'To see more details, re-bootstrap your environment, or otherwise ensure that the deployment role has permissions to call the DescribeEvents API.';
26+
27+
await this.ioHelper.defaults.warn(message);
28+
}
3229

33-
const failures = (eventsOutput.OperationEvents ?? [])
34-
.filter((event) => event.ValidationStatus === ValidationStatus.FAILED)
30+
if (operationEvents.length > 0) {
31+
const failures = operationEvents
3532
.map((event) => ` - ${event.ValidationStatusReason} (at ${event.ValidationPath})`)
3633
.join('\n');
3734

@@ -40,21 +37,13 @@ export class EarlyValidationReporter implements ValidationReporter {
4037
}
4138
}
4239

43-
private async checkBootstrapVersion() {
44-
const environment = this.environmentResources.environment;
45-
let bootstrapVersion: number | undefined = undefined;
46-
try {
47-
// Try to get the bootstrap version
48-
bootstrapVersion = (await this.environmentResources.lookupToolkit()).version;
49-
} catch (e) {
50-
// But if we can't, keep going. Maybe we can still succeed.
51-
}
52-
if (bootstrapVersion != null && bootstrapVersion < 30) {
53-
const env = `aws://${environment.account}/${environment.region}`;
54-
throw new ToolkitError(
55-
'While creating the change set, CloudFormation detected errors in the generated templates.\n' +
56-
`To see details about these errors, re-bootstrap your environment with 'cdk bootstrap ${env}', and run 'cdk deploy' again.`,
57-
);
58-
}
40+
private async getFailedEvents(stackName: string, changeSetName: string) {
41+
return this.sdk.cloudFormation().paginatedDescribeEvents({
42+
StackName: stackName,
43+
ChangeSetName: changeSetName,
44+
Filters: {
45+
FailedEvents: true,
46+
},
47+
});
5948
}
6049
}

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { TestIoHost } from '../../_helpers/test-io-host';
3535

3636
let ioHost = new TestIoHost();
3737
let ioHelper = ioHost.asHelper('deploy');
38+
let ioHelperWarn: jest.SpyInstance<Promise<void>, [input: string, ...args: unknown[]], any>;
3839

3940
function testDeployStack(options: DeployStackApiOptions) {
4041
return deployStack(options, ioHelper);
@@ -112,6 +113,7 @@ beforeEach(() => {
112113
mockCloudFormationClient.on(UpdateTerminationProtectionCommand).resolves({
113114
StackId: 'stack-id',
114115
});
116+
ioHelperWarn = jest.spyOn(ioHelper.defaults, 'warn');
115117
});
116118

117119
function standardDeployStackArguments(): DeployStackApiOptions {
@@ -784,8 +786,27 @@ test('deployStack throws error in case of early validation failures', async () =
784786
testDeployStack({
785787
...standardDeployStackArguments(),
786788
}),
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+
).rejects.toThrow(`ChangeSet 'cdk-deploy-change-set' on stack 'withouterrors' failed early validation:
790+
- Resource already exists (at Resources/MyResource)`);
791+
});
792+
793+
test('deployStack warns when it cannot get the events in case of early validation errors', async () => {
794+
mockCloudFormationClient.on(DescribeChangeSetCommand).resolvesOnce({
795+
Status: ChangeSetStatus.FAILED,
796+
StatusReason: '(AWS::EarlyValidation::SomeError). Blah blah blah.',
797+
});
798+
799+
mockCloudFormationClient.on(DescribeEventsCommand).rejectsOnce({
800+
message: 'AccessDenied',
801+
});
802+
803+
await testDeployStack({
804+
...standardDeployStackArguments(),
805+
});
806+
807+
expect(ioHelperWarn).toHaveBeenCalledWith(
808+
expect.stringContaining('does not have permissions to call the DescribeEvents API'),
809+
);
789810
});
790811

791812
test('deploy not skipped if template did not change but one tag removed', async () => {
Lines changed: 39 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,46 @@
1-
import { ChangeSetStatus } from '@aws-sdk/client-cloudformation';
21
import { EarlyValidationReporter } from '../../../lib/api/deployments/early-validation';
32

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-
});
3+
it('throws an error 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 ioHelperMock = { defaults: { warn: jest.fn() } };
12+
const reporter = new EarlyValidationReporter(sdkMock as any, ioHelperMock as any);
13+
14+
await expect(reporter.report('test-change-set', 'test-stack')).rejects.toThrow(
15+
"ChangeSet 'test-change-set' on stack 'test-stack' failed early validation:\n - Resource already exists (at Resources/MyResource)"
16+
);
17+
});
6418

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-
});
19+
it('does not throw 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 ioHelperMock = { defaults: { warn: jest.fn() } };
26+
const reporter = new EarlyValidationReporter(sdkMock as any, ioHelperMock as any);
27+
28+
await expect(reporter.report('test-change-set', 'test-stack')).resolves.not.toThrow();
29+
expect(ioHelperMock.defaults.warn).not.toHaveBeenCalled();
30+
});
7031

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';
32+
it('logs a warning when DescribeEvents API call fails', async () => {
33+
const sdkMock = {
34+
cloudFormation: jest.fn().mockReturnValue({
35+
paginatedDescribeEvents: jest.fn().mockRejectedValue(new Error('AccessDenied')),
36+
}),
37+
};
38+
const ioHelperMock = { defaults: { warn: jest.fn() } };
39+
const reporter = new EarlyValidationReporter(sdkMock as any, ioHelperMock as any);
7940

80-
mockEnvironmentResources.lookupToolkit.mockResolvedValue({ version: 29 });
41+
await reporter.report('test-change-set', 'test-stack');
8142

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-
});
43+
expect(ioHelperMock.defaults.warn).toHaveBeenCalledWith(
44+
expect.stringContaining('While creating the change set, CloudFormation detected errors in the generated templates')
45+
);
8746
});

0 commit comments

Comments
 (0)