Skip to content

Commit 1337b24

Browse files
authored
feat(cli): cdk diff works for Nested Stacks (#18207)
`cdk diff` now compares all template objects (Resources, Outputs, etc) across nested stacks. Closes #5722. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 323281e commit 1337b24

10 files changed

+1159
-116
lines changed

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

Lines changed: 118 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import * as path from 'path';
12
import * as cxapi from '@aws-cdk/cx-api';
23
import { AssetManifest } from 'cdk-assets';
4+
import * as fs from 'fs-extra';
35
import { Tag } from '../cdk-toolkit';
46
import { debug, warning } from '../logging';
57
import { publishAssets } from '../util/asset-publishing';
68
import { Mode, SdkProvider, ISDK } from './aws-auth';
79
import { deployStack, DeployStackResult, destroyStack } from './deploy-stack';
10+
import { LazyListStackResources, ListStackResources } from './evaluate-cloudformation-template';
811
import { ToolkitInfo } from './toolkit-info';
912
import { CloudFormationStack, Template } from './util/cloudformation';
1013
import { StackActivityProgress } from './util/cloudformation/stack-activity-monitor';
@@ -293,25 +296,23 @@ export class CloudFormationDeployments {
293296
this.sdkProvider = props.sdkProvider;
294297
}
295298

296-
public async readCurrentTemplate(stackArtifact: cxapi.CloudFormationStackArtifact): Promise<Template> {
297-
debug(`Reading existing template for stack ${stackArtifact.displayName}.`);
298-
let stackSdk: ISDK | undefined = undefined;
299-
// try to assume the lookup role and fallback to the deploy role
300-
try {
301-
const result = await prepareSdkWithLookupRoleFor(this.sdkProvider, stackArtifact);
302-
if (result.didAssumeRole) {
303-
stackSdk = result.sdk;
304-
}
305-
} catch { }
299+
public async readCurrentTemplateWithNestedStacks(rootStackArtifact: cxapi.CloudFormationStackArtifact): Promise<Template> {
300+
const sdk = await this.prepareSdkWithLookupOrDeployRole(rootStackArtifact);
301+
const deployedTemplate = await this.readCurrentTemplate(rootStackArtifact, sdk);
302+
await this.addNestedTemplatesToGeneratedAndDeployedStacks(rootStackArtifact, sdk, {
303+
generatedTemplate: rootStackArtifact.template,
304+
deployedTemplate: deployedTemplate,
305+
deployedStackName: rootStackArtifact.stackName,
306+
});
307+
return deployedTemplate;
308+
}
306309

307-
if (!stackSdk) {
308-
stackSdk = (await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading)).stackSdk;
310+
public async readCurrentTemplate(stackArtifact: cxapi.CloudFormationStackArtifact, sdk?: ISDK): Promise<Template> {
311+
debug(`Reading existing template for stack ${stackArtifact.displayName}.`);
312+
if (!sdk) {
313+
sdk = await this.prepareSdkWithLookupOrDeployRole(stackArtifact);
309314
}
310-
311-
const cfn = stackSdk.cloudFormation();
312-
313-
const stack = await CloudFormationStack.lookup(cfn, stackArtifact.stackName);
314-
return stack.template();
315+
return this.readCurrentStackTemplate(stackArtifact.stackName, sdk);
315316
}
316317

317318
public async deployStack(options: DeployStackOptions): Promise<DeployStackResult> {
@@ -372,6 +373,95 @@ export class CloudFormationDeployments {
372373
return stack.exists;
373374
}
374375

376+
private async prepareSdkWithLookupOrDeployRole(stackArtifact: cxapi.CloudFormationStackArtifact): Promise<ISDK> {
377+
// try to assume the lookup role
378+
try {
379+
const result = await prepareSdkWithLookupRoleFor(this.sdkProvider, stackArtifact);
380+
if (result.didAssumeRole) {
381+
return result.sdk;
382+
}
383+
} catch { }
384+
// fall back to the deploy role
385+
return (await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading)).stackSdk;
386+
}
387+
388+
private async readCurrentStackTemplate(stackName: string, stackSdk: ISDK) : Promise<Template> {
389+
const cfn = stackSdk.cloudFormation();
390+
const stack = await CloudFormationStack.lookup(cfn, stackName);
391+
return stack.template();
392+
}
393+
394+
private async addNestedTemplatesToGeneratedAndDeployedStacks(
395+
rootStackArtifact: cxapi.CloudFormationStackArtifact,
396+
sdk: ISDK,
397+
parentTemplates: StackTemplates,
398+
): Promise<void> {
399+
const listStackResources = parentTemplates.deployedStackName ? new LazyListStackResources(sdk, parentTemplates.deployedStackName) : undefined;
400+
for (const [nestedStackLogicalId, generatedNestedStackResource] of Object.entries(parentTemplates.generatedTemplate.Resources ?? {})) {
401+
if (!this.isCdkManagedNestedStack(generatedNestedStackResource)) {
402+
continue;
403+
}
404+
405+
const assetPath = generatedNestedStackResource.Metadata['aws:asset:path'];
406+
const nestedStackTemplates = await this.getNestedStackTemplates(rootStackArtifact, assetPath, nestedStackLogicalId, listStackResources, sdk);
407+
408+
generatedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.generatedTemplate;
409+
410+
const deployedParentTemplate = parentTemplates.deployedTemplate;
411+
deployedParentTemplate.Resources = deployedParentTemplate.Resources ?? {};
412+
const deployedNestedStackResource = deployedParentTemplate.Resources[nestedStackLogicalId] ?? {};
413+
deployedParentTemplate.Resources[nestedStackLogicalId] = deployedNestedStackResource;
414+
deployedNestedStackResource.Type = deployedNestedStackResource.Type ?? 'AWS::CloudFormation::Stack';
415+
deployedNestedStackResource.Properties = deployedNestedStackResource.Properties ?? {};
416+
deployedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.deployedTemplate;
417+
418+
await this.addNestedTemplatesToGeneratedAndDeployedStacks(
419+
rootStackArtifact,
420+
sdk,
421+
nestedStackTemplates,
422+
);
423+
}
424+
}
425+
426+
private async getNestedStackTemplates(
427+
rootStackArtifact: cxapi.CloudFormationStackArtifact, nestedTemplateAssetPath: string, nestedStackLogicalId: string,
428+
listStackResources: ListStackResources | undefined, sdk: ISDK,
429+
): Promise<StackTemplates> {
430+
const nestedTemplatePath = path.join(rootStackArtifact.assembly.directory, nestedTemplateAssetPath);
431+
432+
// CFN generates the nested stack name in the form `ParentStackName-NestedStackLogicalID-SomeHashWeCan'tCompute,
433+
// the arn is of the form: arn:aws:cloudformation:region:123456789012:stack/NestedStackName/AnotherHashWeDon'tNeed
434+
// so we get the ARN and manually extract the name.
435+
const nestedStackArn = await this.getNestedStackArn(nestedStackLogicalId, listStackResources);
436+
const deployedStackName = nestedStackArn?.slice(nestedStackArn.indexOf('/') + 1, nestedStackArn.lastIndexOf('/'));
437+
438+
return {
439+
generatedTemplate: JSON.parse(fs.readFileSync(nestedTemplatePath, 'utf-8')),
440+
deployedTemplate: deployedStackName
441+
? await this.readCurrentStackTemplate(deployedStackName, sdk)
442+
: {},
443+
deployedStackName,
444+
};
445+
}
446+
447+
private async getNestedStackArn(
448+
nestedStackLogicalId: string, listStackResources?: ListStackResources,
449+
): Promise<string | undefined> {
450+
try {
451+
const stackResources = await listStackResources?.listStackResources();
452+
return stackResources?.find(sr => sr.LogicalResourceId === nestedStackLogicalId)?.PhysicalResourceId;
453+
} catch (e) {
454+
if (e.message.startsWith('Stack with id ') && e.message.endsWith(' does not exist')) {
455+
return;
456+
}
457+
throw e;
458+
}
459+
}
460+
461+
private isCdkManagedNestedStack(stackResource: any): stackResource is NestedStackResource {
462+
return stackResource.Type === 'AWS::CloudFormation::Stack' && stackResource.Metadata && stackResource.Metadata['aws:asset:path'];
463+
}
464+
375465
/**
376466
* Get the environment necessary for touching the given stack
377467
*
@@ -453,3 +543,14 @@ export class CloudFormationDeployments {
453543
function isAssetManifestArtifact(art: cxapi.CloudArtifact): art is cxapi.AssetManifestArtifact {
454544
return art instanceof cxapi.AssetManifestArtifact;
455545
}
546+
547+
interface StackTemplates {
548+
readonly generatedTemplate: any;
549+
readonly deployedTemplate: any;
550+
readonly deployedStackName: string | undefined;
551+
}
552+
553+
interface NestedStackResource {
554+
readonly Metadata: { 'aws:asset:path': string };
555+
readonly Properties: any;
556+
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,3 @@ async function applyHotswappableChange(sdk: ISDK, hotswapOperation: HotswapOpera
234234
sdk.removeCustomUserAgent(customUserAgent);
235235
}
236236
}
237-

packages/aws-cdk/lib/cdk-toolkit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import * as path from 'path';
22
import { format } from 'util';
33
import * as cxapi from '@aws-cdk/cx-api';
4-
import * as chokidar from 'chokidar';
54
import * as chalk from 'chalk';
5+
import * as chokidar from 'chokidar';
66
import * as fs from 'fs-extra';
77
import * as promptly from 'promptly';
88
import { environmentsFromDescriptors, globEnvironmentsFromStacks, looksLikeGlob } from '../lib/api/cxapp/environments';
@@ -104,7 +104,7 @@ export class CdkToolkit {
104104
// Compare N stacks against deployed templates
105105
for (const stack of stacks.stackArtifacts) {
106106
stream.write(format('Stack %s\n', chalk.bold(stack.displayName)));
107-
const currentTemplate = await this.props.cloudFormation.readCurrentTemplate(stack);
107+
const currentTemplate = await this.props.cloudFormation.readCurrentTemplateWithNestedStacks(stack);
108108
diffs += options.securityOnly
109109
? numberFromBool(printSecurityDiff(currentTemplate, stack, RequireApproval.Broadening))
110110
: printStackDiff(currentTemplate, stack, strict, contextLines, stream);

0 commit comments

Comments
 (0)