Skip to content

Commit 135b520

Browse files
authored
feat(CLI): improved nested stack diff (#29172)
### Issue # (if applicable) ### Reason for this change The existing nested stack diff places a fake property, `NestedTemplate`, in the templates to be diffed. This prevents displaying resource replacement information in the diff, like we do for top level stacks. This PR does *not* add changeset replacement information from changesets, but it does add replacement information from the spec. ### Description of changes Reworked nested stack diff to treat nested stacks as top level stacks. This improves the visual UX and sets us up for using changesets with nested stacks. #### Before <img width="957" alt="Screenshot 2024-02-19 at 1 47 59 PM" src="https://github.com/aws/aws-cdk/assets/66279577/a94275c4-e7c3-4d2c-a924-ee61c36bea4d"> #### After <img width="957" alt="Screenshot 2024-02-19 at 1 48 48 PM" src="https://github.com/aws/aws-cdk/assets/66279577/5263aaf9-ef2f-4228-b413-81e780c4b8f8"> ### Description of how you validated changes Unit tests + manual tests. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 4582ac5 commit 135b520

14 files changed

+757
-463
lines changed

packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function fullDiff(
5454
normalize(newTemplate);
5555
const theDiff = diffTemplate(currentTemplate, newTemplate);
5656
if (changeSet) {
57-
filterFalsePositivies(theDiff, changeSet);
57+
filterFalsePositives(theDiff, changeSet);
5858
addImportInformation(theDiff, changeSet);
5959
}
6060
if (isImport) {
@@ -64,7 +64,7 @@ export function fullDiff(
6464
return theDiff;
6565
}
6666

67-
function diffTemplate(
67+
export function diffTemplate(
6868
currentTemplate: { [key: string]: any },
6969
newTemplate: { [key: string]: any },
7070
): types.TemplateDiff {
@@ -235,7 +235,7 @@ function addImportInformation(diff: types.TemplateDiff, changeSet?: CloudFormati
235235
}
236236
}
237237

238-
function filterFalsePositivies(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {
238+
function filterFalsePositives(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {
239239
const replacements = findResourceReplacements(changeSet);
240240
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
241241
if (change.resourceType.includes('AWS::Serverless')) {

packages/@aws-cdk/cloudformation-diff/lib/format.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export interface FormatStream extends NodeJS.WritableStream {
3030
export function formatDifferences(
3131
stream: FormatStream,
3232
templateDiff: TemplateDiff,
33-
logicalToPathMap: { [logicalId: string]: string } = { },
33+
logicalToPathMap: { [logicalId: string]: string } = {},
3434
context: number = 3) {
3535
const formatter = new Formatter(stream, logicalToPathMap, templateDiff, context);
3636

@@ -59,7 +59,7 @@ export function formatDifferences(
5959
export function formatSecurityChanges(
6060
stream: NodeJS.WritableStream,
6161
templateDiff: TemplateDiff,
62-
logicalToPathMap: {[logicalId: string]: string} = {},
62+
logicalToPathMap: { [logicalId: string]: string } = {},
6363
context?: number) {
6464
const formatter = new Formatter(stream, logicalToPathMap, templateDiff, context);
6565

@@ -254,7 +254,7 @@ class Formatter {
254254
const oldStr = JSON.stringify(oldObject, null, 2);
255255
const newStr = JSON.stringify(newObject, null, 2);
256256
const diff = _diffStrings(oldStr, newStr, this.context);
257-
for (let i = 0 ; i < diff.length ; i++) {
257+
for (let i = 0; i < diff.length; i++) {
258258
this.print('%s %s %s', linePrefix, i === 0 ? '└─' : ' ', diff[i]);
259259
}
260260
} else {
@@ -466,7 +466,7 @@ function _diffStrings(oldStr: string, newStr: string, context: number): string[]
466466
function _findIndent(lines: string[]): number {
467467
let indent = Number.MAX_SAFE_INTEGER;
468468
for (const line of lines) {
469-
for (let i = 1 ; i < line.length ; i++) {
469+
for (let i = 1; i < line.length; i++) {
470470
if (line.charAt(i) !== ' ') {
471471
indent = indent > i - 1 ? i - 1 : indent;
472472
break;

packages/aws-cdk/lib/api/deployments.ts

+3-7
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { CredentialsOptions, SdkForEnvironment, SdkProvider } from './aws-auth/s
77
import { deployStack, DeployStackResult, destroyStack, DeploymentMethod } from './deploy-stack';
88
import { EnvironmentResources, EnvironmentResourcesRegistry } from './environment-resources';
99
import { HotswapMode } from './hotswap/common';
10-
import { loadCurrentTemplateWithNestedStacks, loadCurrentTemplate, flattenNestedStackNames, TemplateWithNestedStackCount } from './nested-stack-helpers';
10+
import { loadCurrentTemplateWithNestedStacks, loadCurrentTemplate, RootTemplateWithNestedStacks } from './nested-stack-helpers';
1111
import { CloudFormationStack, Template, ResourcesToImport, ResourceIdentifierSummaries } from './util/cloudformation';
1212
import { StackActivityProgress } from './util/cloudformation/stack-activity-monitor';
1313
import { replaceEnvPlaceholders } from './util/placeholders';
@@ -327,13 +327,9 @@ export class Deployments {
327327
public async readCurrentTemplateWithNestedStacks(
328328
rootStackArtifact: cxapi.CloudFormationStackArtifact,
329329
retrieveProcessedTemplate: boolean = false,
330-
): Promise<TemplateWithNestedStackCount> {
330+
): Promise<RootTemplateWithNestedStacks> {
331331
const sdk = (await this.prepareSdkWithLookupOrDeployRole(rootStackArtifact)).stackSdk;
332-
const templateWithNestedStacks = await loadCurrentTemplateWithNestedStacks(rootStackArtifact, sdk, retrieveProcessedTemplate);
333-
return {
334-
deployedTemplate: templateWithNestedStacks.deployedTemplate,
335-
nestedStackCount: flattenNestedStackNames(templateWithNestedStacks.nestedStackNames).length,
336-
};
332+
return loadCurrentTemplateWithNestedStacks(rootStackArtifact, sdk, retrieveProcessedTemplate);
337333
}
338334

339335
public async readCurrentTemplate(stackArtifact: cxapi.CloudFormationStackArtifact): Promise<Template> {

packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts

+16-18
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as AWS from 'aws-sdk';
22
import { PromiseResult } from 'aws-sdk/lib/request';
33
import { ISDK } from './aws-auth';
4-
import { NestedStackNames } from './nested-stack-helpers';
4+
import { NestedStackTemplates } from './nested-stack-helpers';
55

66
export interface ListStackResources {
77
listStackResources(): Promise<AWS.CloudFormation.StackResourceSummary[]>;
@@ -102,7 +102,7 @@ export interface EvaluateCloudFormationTemplateProps {
102102
readonly partition: string;
103103
readonly urlSuffix: (region: string) => string;
104104
readonly sdk: ISDK;
105-
readonly nestedStackNames?: { [nestedStackLogicalId: string]: NestedStackNames };
105+
readonly nestedStacks?: { [nestedStackLogicalId: string]: NestedStackTemplates };
106106
}
107107

108108
export class EvaluateCloudFormationTemplate {
@@ -114,7 +114,7 @@ export class EvaluateCloudFormationTemplate {
114114
private readonly partition: string;
115115
private readonly urlSuffix: (region: string) => string;
116116
private readonly sdk: ISDK;
117-
private readonly nestedStackNames: { [nestedStackLogicalId: string]: NestedStackNames };
117+
private readonly nestedStacks: { [nestedStackLogicalId: string]: NestedStackTemplates };
118118
private readonly stackResources: ListStackResources;
119119
private readonly lookupExport: LookupExport;
120120

@@ -136,7 +136,7 @@ export class EvaluateCloudFormationTemplate {
136136
this.sdk = props.sdk;
137137

138138
// We need names of nested stack so we can evaluate cross stack references
139-
this.nestedStackNames = props.nestedStackNames ?? {};
139+
this.nestedStacks = props.nestedStacks ?? {};
140140

141141
// The current resources of the Stack.
142142
// We need them to figure out the physical name of a resource in case it wasn't specified by the user.
@@ -163,7 +163,7 @@ export class EvaluateCloudFormationTemplate {
163163
partition: this.partition,
164164
urlSuffix: this.urlSuffix,
165165
sdk: this.sdk,
166-
nestedStackNames: this.nestedStackNames,
166+
nestedStacks: this.nestedStacks,
167167
});
168168
}
169169

@@ -386,17 +386,15 @@ export class EvaluateCloudFormationTemplate {
386386
}
387387

388388
if (foundResource.ResourceType == 'AWS::CloudFormation::Stack' && attribute?.startsWith('Outputs.')) {
389-
// need to resolve attributes from another stack's Output section
390-
const dependantStackName = this.findNestedStack(logicalId, this.nestedStackNames);
391-
if (!dependantStackName) {
389+
const dependantStack = this.findNestedStack(logicalId, this.nestedStacks);
390+
if (!dependantStack || !dependantStack.physicalName) {
392391
//this is a newly created nested stack and cannot be hotswapped
393392
return undefined;
394393
}
395-
const dependantStackTemplate = this.template.Resources[logicalId];
396394
const evaluateCfnTemplate = await this.createNestedEvaluateCloudFormationTemplate(
397-
dependantStackName,
398-
dependantStackTemplate?.Properties?.NestedTemplate,
399-
dependantStackTemplate.newValue?.Properties?.Parameters);
395+
dependantStack.physicalName,
396+
dependantStack.generatedTemplate,
397+
dependantStack.generatedTemplate.Parameters!);
400398

401399
// Split Outputs.<refName> into 'Outputs' and '<refName>' and recursively call evaluate
402400
return evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': attribute.split(/\.(.*)/s) });
@@ -406,14 +404,14 @@ export class EvaluateCloudFormationTemplate {
406404
return this.formatResourceAttribute(foundResource, attribute);
407405
}
408406

409-
private findNestedStack(logicalId: string, nestedStackNames: {
410-
[nestedStackLogicalId: string]: NestedStackNames;
411-
}): string | undefined {
412-
for (const [nestedStackLogicalId, { nestedChildStackNames, nestedStackPhysicalName }] of Object.entries(nestedStackNames)) {
407+
private findNestedStack(logicalId: string, nestedStacks: {
408+
[nestedStackLogicalId: string]: NestedStackTemplates;
409+
}): NestedStackTemplates | undefined {
410+
for (const nestedStackLogicalId of Object.keys(nestedStacks)) {
413411
if (nestedStackLogicalId === logicalId) {
414-
return nestedStackPhysicalName;
412+
return nestedStacks[nestedStackLogicalId];
415413
}
416-
const checkInNestedChildStacks = this.findNestedStack(logicalId, nestedChildStackNames);
414+
const checkInNestedChildStacks = this.findNestedStack(logicalId, nestedStacks[nestedStackLogicalId].nestedStackTemplates);
417415
if (checkInNestedChildStacks) return checkInNestedChildStacks;
418416
}
419417
return undefined;

packages/aws-cdk/lib/api/hotswap-deployments.ts

+11-11
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { isHotswappableEcsServiceChange } from './hotswap/ecs-services';
1111
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
1212
import { skipChangeForS3DeployCustomResourcePolicy, isHotswappableS3BucketDeploymentChange } from './hotswap/s3-bucket-deployments';
1313
import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines';
14-
import { loadCurrentTemplateWithNestedStacks, NestedStackNames } from './nested-stack-helpers';
14+
import { NestedStackTemplates, loadCurrentTemplateWithNestedStacks } from './nested-stack-helpers';
1515
import { CloudFormationStack } from './util/cloudformation';
1616
import { print } from '../logging';
1717

@@ -78,12 +78,12 @@ export async function tryHotswapDeployment(
7878
partition: (await sdk.currentAccount()).partition,
7979
urlSuffix: (region) => sdk.getEndpointSuffix(region),
8080
sdk,
81-
nestedStackNames: currentTemplate.nestedStackNames,
81+
nestedStacks: currentTemplate.nestedStacks,
8282
});
8383

84-
const stackChanges = cfn_diff.fullDiff(currentTemplate.deployedTemplate, stackArtifact.template);
84+
const stackChanges = cfn_diff.fullDiff(currentTemplate.deployedRootTemplate, stackArtifact.template);
8585
const { hotswappableChanges, nonHotswappableChanges } = await classifyResourceChanges(
86-
stackChanges, evaluateCfnTemplate, sdk, currentTemplate.nestedStackNames,
86+
stackChanges, evaluateCfnTemplate, sdk, currentTemplate.nestedStacks,
8787
);
8888

8989
logNonHotswappableChanges(nonHotswappableChanges, hotswapMode);
@@ -109,7 +109,7 @@ async function classifyResourceChanges(
109109
stackChanges: cfn_diff.TemplateDiff,
110110
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
111111
sdk: ISDK,
112-
nestedStackNames: { [nestedStackName: string]: NestedStackNames },
112+
nestedStackNames: { [nestedStackName: string]: NestedStackTemplates },
113113
): Promise<ClassifiedResourceChanges> {
114114
const resourceDifferences = getStackResourceDifferences(stackChanges);
115115

@@ -225,12 +225,12 @@ function filterDict<T>(dict: { [key: string]: T }, func: (t: T) => boolean): { [
225225
async function findNestedHotswappableChanges(
226226
logicalId: string,
227227
change: cfn_diff.ResourceDifference,
228-
nestedStackNames: { [nestedStackName: string]: NestedStackNames },
228+
nestedStackTemplates: { [nestedStackName: string]: NestedStackTemplates },
229229
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
230230
sdk: ISDK,
231231
): Promise<ClassifiedResourceChanges> {
232-
const nestedStackName = nestedStackNames[logicalId].nestedStackPhysicalName;
233-
if (!nestedStackName) {
232+
const nestedStack = nestedStackTemplates[logicalId];
233+
if (!nestedStack.physicalName) {
234234
return {
235235
hotswappableChanges: [],
236236
nonHotswappableChanges: [{
@@ -244,14 +244,14 @@ async function findNestedHotswappableChanges(
244244
}
245245

246246
const evaluateNestedCfnTemplate = await evaluateCfnTemplate.createNestedEvaluateCloudFormationTemplate(
247-
nestedStackName, change.newValue?.Properties?.NestedTemplate, change.newValue?.Properties?.Parameters,
247+
nestedStack.physicalName, nestedStack.generatedTemplate, change.newValue?.Properties?.Parameters,
248248
);
249249

250250
const nestedDiff = cfn_diff.fullDiff(
251-
change.oldValue?.Properties?.NestedTemplate, change.newValue?.Properties?.NestedTemplate,
251+
nestedStackTemplates[logicalId].deployedTemplate, nestedStackTemplates[logicalId].generatedTemplate,
252252
);
253253

254-
return classifyResourceChanges(nestedDiff, evaluateNestedCfnTemplate, sdk, nestedStackNames[logicalId].nestedChildStackNames);
254+
return classifyResourceChanges(nestedDiff, evaluateNestedCfnTemplate, sdk, nestedStackTemplates[logicalId].nestedStackTemplates);
255255
}
256256

257257
/** Returns 'true' if a pair of changes is for the same resource. */

packages/aws-cdk/lib/api/nested-stack-helpers.ts

+23-62
Original file line numberDiff line numberDiff line change
@@ -5,59 +5,38 @@ import { ISDK } from './aws-auth';
55
import { LazyListStackResources, ListStackResources } from './evaluate-cloudformation-template';
66
import { CloudFormationStack, Template } from './util/cloudformation';
77

8-
export interface TemplateWithNestedStackNames {
8+
export interface NestedStackTemplates {
9+
readonly physicalName: string | undefined;
910
readonly deployedTemplate: Template;
10-
readonly nestedStackNames: { [nestedStackLogicalId: string]: NestedStackNames };
11+
readonly generatedTemplate: Template;
12+
readonly nestedStackTemplates: { [nestedStackLogicalId: string]: NestedStackTemplates};
1113
}
1214

13-
export interface NestedStackNames {
14-
readonly nestedStackPhysicalName: string | undefined;
15-
readonly nestedChildStackNames: { [logicalId: string]: NestedStackNames };
16-
}
17-
18-
export interface TemplateWithNestedStackCount {
19-
readonly deployedTemplate: Template;
20-
readonly nestedStackCount: number;
15+
export interface RootTemplateWithNestedStacks {
16+
readonly deployedRootTemplate: Template;
17+
readonly nestedStacks: { [nestedStackLogicalId: string]: NestedStackTemplates };
2118
}
2219

2320
/**
24-
* Reads the currently deployed template from CloudFormation and adds a
25-
* property, `NestedTemplate`, to any nested stacks that appear in either
26-
* the deployed template or the newly synthesized template. `NestedTemplate`
27-
* is populated with contents of the nested template by mutating the
28-
* `template` property of `rootStackArtifact`. This is done for all
29-
* nested stack resources to arbitrary depths.
21+
* Reads the currently deployed template and all of its nested stack templates from CloudFormation.
3022
*/
3123
export async function loadCurrentTemplateWithNestedStacks(
3224
rootStackArtifact: cxapi.CloudFormationStackArtifact, sdk: ISDK,
3325
retrieveProcessedTemplate: boolean = false,
34-
): Promise<TemplateWithNestedStackNames> {
35-
const deployedTemplate = await loadCurrentTemplate(rootStackArtifact, sdk, retrieveProcessedTemplate);
36-
const nestedStackNames = await addNestedTemplatesToGeneratedAndDeployedStacks(rootStackArtifact, sdk, {
26+
): Promise<RootTemplateWithNestedStacks> {
27+
const deployedRootTemplate = await loadCurrentTemplate(rootStackArtifact, sdk, retrieveProcessedTemplate);
28+
const nestedStacks = await loadNestedStacks(rootStackArtifact, sdk, {
3729
generatedTemplate: rootStackArtifact.template,
38-
deployedTemplate: deployedTemplate,
30+
deployedTemplate: deployedRootTemplate,
3931
deployedStackName: rootStackArtifact.stackName,
4032
});
4133

4234
return {
43-
deployedTemplate,
44-
nestedStackNames,
35+
deployedRootTemplate,
36+
nestedStacks,
4537
};
4638
}
4739

48-
export function flattenNestedStackNames(nestedStackNames: { [nestedStackLogicalId: string]: NestedStackNames }): string[] {
49-
const nameList = [];
50-
for (const key of Object.keys(nestedStackNames)) {
51-
nameList.push(key);
52-
53-
if (Object.keys(nestedStackNames[key].nestedChildStackNames).length !== 0) {
54-
flattenNestedStacksHelper(nestedStackNames[key].nestedChildStackNames, nameList);
55-
}
56-
}
57-
58-
return nameList;
59-
}
60-
6140
/**
6241
* Returns the currently deployed template from CloudFormation that corresponds to `stackArtifact`.
6342
*/
@@ -76,13 +55,13 @@ async function loadCurrentStackTemplate(
7655
return stack.template();
7756
}
7857

79-
async function addNestedTemplatesToGeneratedAndDeployedStacks(
58+
async function loadNestedStacks(
8059
rootStackArtifact: cxapi.CloudFormationStackArtifact,
8160
sdk: ISDK,
8261
parentTemplates: StackTemplates,
83-
): Promise<{ [nestedStackLogicalId: string]: NestedStackNames }> {
62+
): Promise<{ [nestedStackLogicalId: string]: NestedStackTemplates }> {
8463
const listStackResources = parentTemplates.deployedStackName ? new LazyListStackResources(sdk, parentTemplates.deployedStackName) : undefined;
85-
const nestedStackNames: { [nestedStackLogicalId: string]: NestedStackNames } = {};
64+
const nestedStacks: { [nestedStackLogicalId: string]: NestedStackTemplates } = {};
8665
for (const [nestedStackLogicalId, generatedNestedStackResource] of Object.entries(parentTemplates.generatedTemplate.Resources ?? {})) {
8766
if (!isCdkManagedNestedStack(generatedNestedStackResource)) {
8867
continue;
@@ -91,27 +70,19 @@ async function addNestedTemplatesToGeneratedAndDeployedStacks(
9170
const assetPath = generatedNestedStackResource.Metadata['aws:asset:path'];
9271
const nestedStackTemplates = await getNestedStackTemplates(rootStackArtifact, assetPath, nestedStackLogicalId, listStackResources, sdk);
9372

94-
generatedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.generatedTemplate;
95-
96-
const deployedParentTemplate = parentTemplates.deployedTemplate;
97-
deployedParentTemplate.Resources = deployedParentTemplate.Resources ?? {};
98-
const deployedNestedStackResource = deployedParentTemplate.Resources[nestedStackLogicalId] ?? {};
99-
deployedParentTemplate.Resources[nestedStackLogicalId] = deployedNestedStackResource;
100-
deployedNestedStackResource.Type = deployedNestedStackResource.Type ?? 'AWS::CloudFormation::Stack';
101-
deployedNestedStackResource.Properties = deployedNestedStackResource.Properties ?? {};
102-
deployedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.deployedTemplate;
103-
104-
nestedStackNames[nestedStackLogicalId] = {
105-
nestedStackPhysicalName: nestedStackTemplates.deployedStackName,
106-
nestedChildStackNames: await addNestedTemplatesToGeneratedAndDeployedStacks(
73+
nestedStacks[nestedStackLogicalId] = {
74+
deployedTemplate: nestedStackTemplates.deployedTemplate,
75+
generatedTemplate: nestedStackTemplates.generatedTemplate,
76+
physicalName: nestedStackTemplates.deployedStackName,
77+
nestedStackTemplates: await loadNestedStacks(
10778
rootStackArtifact,
10879
sdk,
10980
nestedStackTemplates,
11081
),
11182
};
11283
}
11384

114-
return nestedStackNames;
85+
return nestedStacks;
11586
}
11687

11788
async function getNestedStackTemplates(
@@ -153,16 +124,6 @@ function isCdkManagedNestedStack(stackResource: any): stackResource is NestedSta
153124
return stackResource.Type === 'AWS::CloudFormation::Stack' && stackResource.Metadata && stackResource.Metadata['aws:asset:path'];
154125
}
155126

156-
function flattenNestedStacksHelper(nestedStackNames: { [logicalId: string]: NestedStackNames }, nameList: string[]) {
157-
for (const key of Object.keys(nestedStackNames)) {
158-
nameList.push(key);
159-
160-
if (Object.keys(nestedStackNames[key].nestedChildStackNames).length !== 0) {
161-
flattenNestedStacksHelper(nestedStackNames[key].nestedChildStackNames, nameList);
162-
}
163-
}
164-
}
165-
166127
interface StackTemplates {
167128
readonly generatedTemplate: any;
168129
readonly deployedTemplate: any;

0 commit comments

Comments
 (0)