Skip to content

Commit 0a046cf

Browse files
authored
feat(toolkit-lib): fallBackToTemplate option for diffs (#354)
The primary motivation for this PR is aws/aws-cdk#28753 and the ensuing PR aws/aws-cdk#32830. When implementing diff in the programmatic toolkit library we included the diff method options, and this PR adds the `fallBackToTemplate` property to the `ChangeSet` mode. The idea is that people can specify `fallBackToTemplate=false` to explicitly fail if we cannot successfully create the changeset. The current behavior is to fallback to the template-only method. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 6cde334 commit 0a046cf

File tree

4 files changed

+90
-20
lines changed

4 files changed

+90
-20
lines changed

packages/@aws-cdk/tmp-toolkit-helpers/src/api/deployments/cfn-api.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ export type PrepareChangeSetOptions = {
142142
sdkProvider: SdkProvider;
143143
parameters: { [name: string]: string | undefined };
144144
resourcesToImport?: ResourcesToImport;
145+
/**
146+
* Default behavior is to log AWS CloudFormation errors and move on. Set this property to true to instead
147+
* fail on errors received by AWS CloudFormation.
148+
*
149+
* @default false
150+
*/
151+
failOnError?: boolean;
145152
}
146153

147154
export type CreateChangeSetOptions = {
@@ -240,12 +247,17 @@ async function uploadBodyParameterAndCreateChangeSet(
240247
role: executionRoleArn,
241248
});
242249
} catch (e: any) {
243-
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(String(e)));
244-
await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(
245-
'Could not create a change set, will base the diff on template differences (run again with -v to see the reason)\n',
246-
));
250+
// This function is currently only used by diff so these messages are diff-specific
251+
if (!options.failOnError) {
252+
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(String(e)));
253+
await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(
254+
'Could not create a change set, will base the diff on template differences (run again with -v to see the reason)\n',
255+
));
247256

248-
return undefined;
257+
return undefined;
258+
}
259+
260+
throw new ToolkitError('Could not create a change set and failOnError is set. (run again with failOnError off to base the diff on template differences)\n', e);
249261
}
250262
}
251263

packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts

+8-9
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@ export interface CloudFormationDiffOptions {
1111
}
1212

1313
export interface ChangeSetDiffOptions extends CloudFormationDiffOptions {
14-
// @TODO: add this as a feature
15-
// /**
16-
// * Enable falling back to template-based diff in case creating the changeset is not possible or results in an error.
17-
// *
18-
// * Should be used for stacks containing nested stacks or when change set permissions aren't available.
19-
// *
20-
// * @default true
21-
// */
22-
// readonly fallbackToTemplate?: boolean;
14+
/**
15+
* Enable falling back to template-based diff in case creating the changeset is not possible or results in an error.
16+
*
17+
* Should be used for stacks containing nested stacks or when change set permissions aren't available.
18+
*
19+
* @default true
20+
*/
21+
readonly fallbackToTemplate?: boolean;
2322

2423
/**
2524
* Additional parameters for CloudFormation when creating a diff change set

packages/@aws-cdk/toolkit-lib/lib/actions/diff/private/helpers.ts

+23-3
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ async function cfnDiff(
5858
deployments: Deployments,
5959
options: DiffOptions,
6060
sdkProvider: SdkProvider,
61-
changeSet: boolean,
61+
includeChangeSet: boolean,
6262
): Promise<TemplateInfo[]> {
6363
const templateInfos = [];
6464
const methodOptions = (options.method?.options ?? {}) as ChangeSetDiffOptions;
@@ -78,13 +78,23 @@ async function cfnDiff(
7878
removeNonImportResources(stack);
7979
}
8080

81+
const changeSet = includeChangeSet ? await changeSetDiff(
82+
ioHelper,
83+
deployments,
84+
stack,
85+
sdkProvider,
86+
resourcesToImport,
87+
methodOptions.parameters,
88+
methodOptions.fallbackToTemplate,
89+
) : undefined;
90+
8191
templateInfos.push({
8292
oldTemplate: currentTemplate,
8393
newTemplate: stack,
8494
stackName: stack.stackName,
8595
isImport: !!resourcesToImport,
8696
nestedStacks,
87-
changeSet: changeSet ? await changeSetDiff(ioHelper, deployments, stack, sdkProvider, resourcesToImport, methodOptions.parameters) : undefined,
97+
changeSet,
8898
});
8999
}
90100

@@ -98,6 +108,7 @@ async function changeSetDiff(
98108
sdkProvider: SdkProvider,
99109
resourcesToImport?: ResourcesToImport,
100110
parameters: { [name: string]: string | undefined } = {},
111+
fallBackToTemplate: boolean = true,
101112
): Promise<any | undefined> {
102113
let stackExists = false;
103114
try {
@@ -107,6 +118,10 @@ async function changeSetDiff(
107118
tryLookupRole: true,
108119
});
109120
} catch (e: any) {
121+
if (!fallBackToTemplate) {
122+
throw new ToolkitError(`describeStacks call failed with ${e} for ${stack.stackName}, set fallBackToTemplate to true or use DiffMethod.templateOnly to base the diff on template differences.`);
123+
}
124+
110125
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Checking if the stack ${stack.stackName} exists before creating the changeset has failed, will base the diff on template differences.\n`));
111126
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(formatErrorMessage(e)));
112127
stackExists = false;
@@ -121,9 +136,14 @@ async function changeSetDiff(
121136
sdkProvider,
122137
parameters: parameters,
123138
resourcesToImport,
139+
failOnError: !fallBackToTemplate,
124140
});
125141
} else {
126-
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`the stack '${stack.stackName}' has not been deployed to CloudFormation or describeStacks call failed, skipping changeset creation.`));
142+
if (!fallBackToTemplate) {
143+
throw new ToolkitError(`the stack '${stack.stackName}' has not been deployed to CloudFormation, set fallBackToTemplate to true or use DiffMethod.templateOnly to base the diff on template differences.`);
144+
}
145+
146+
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`the stack '${stack.stackName}' has not been deployed to CloudFormation, skipping changeset creation.`));
127147
return;
128148
}
129149
}

packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts

+42-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import * as path from 'path';
2-
import { format } from 'util';
32
import * as chalk from 'chalk';
43
import { DiffMethod } from '../../lib/actions/diff';
54
import * as apis from '../../lib/api/shared-private';
@@ -18,7 +17,6 @@ beforeEach(() => {
1817
ioHost.requireDeployApproval = RequireApproval.NEVER;
1918

2019
toolkit = new Toolkit({ ioHost });
21-
const sdk = new MockSdk();
2220

2321
// Some default implementations
2422
jest.spyOn(apis.Deployments.prototype, 'readCurrentTemplateWithNestedStacks').mockResolvedValue({
@@ -177,7 +175,48 @@ describe('diff', () => {
177175
}));
178176
});
179177

180-
describe('templatePath', () => {
178+
describe('DiffMethod.ChangeSet', () => {
179+
test('ChangeSet diff method falls back to template only if changeset not found', async () => {
180+
// WHEN
181+
ioHost.level = 'debug';
182+
const cx = await builderFixture(toolkit, 'stack-with-bucket');
183+
await toolkit.diff(cx, {
184+
stacks: { strategy: StackSelectionStrategy.ALL_STACKS },
185+
method: DiffMethod.ChangeSet(),
186+
});
187+
188+
// THEN
189+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
190+
action: 'diff',
191+
level: 'info',
192+
code: 'CDK_TOOLKIT_I0000',
193+
message: expect.stringContaining('Could not create a change set, will base the diff on template differences'),
194+
}));
195+
});
196+
197+
test('ChangeSet diff method throws if changeSet fails and fallBackToTemplate = false', async () => {
198+
// WHEN
199+
const cx = await builderFixture(toolkit, 'stack-with-bucket');
200+
await expect(async () => toolkit.diff(cx, {
201+
stacks: { strategy: StackSelectionStrategy.ALL_STACKS },
202+
method: DiffMethod.ChangeSet({ fallbackToTemplate: false }),
203+
})).rejects.toThrow(/Could not create a change set and failOnError is set/);
204+
});
205+
206+
test('ChangeSet diff method throws if stack not found and fallBackToTemplate = false', async () => {
207+
// GIVEN
208+
jest.spyOn(apis.Deployments.prototype, 'stackExists').mockResolvedValue(false);
209+
210+
// WHEN
211+
const cx = await builderFixture(toolkit, 'stack-with-bucket');
212+
await expect(async () => toolkit.diff(cx, {
213+
stacks: { strategy: StackSelectionStrategy.ALL_STACKS },
214+
method: DiffMethod.ChangeSet({ fallbackToTemplate: false }),
215+
})).rejects.toThrow(/the stack 'Stack1' has not been deployed to CloudFormation/);
216+
});
217+
});
218+
219+
describe('DiffMethod.LocalFile', () => {
181220
test('fails with multiple stacks', async () => {
182221
// WHEN + THEN
183222
const cx = await builderFixture(toolkit, 'two-empty-stacks');

0 commit comments

Comments
 (0)