Skip to content

Commit 2ea9da1

Browse files
authored
feat(cli): hotswap support for resources in nested stacks (#18950)
Resources in nested stacks can now be hotswapped. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 96b2034 commit 2ea9da1

22 files changed

+1484
-129
lines changed

packages/aws-cdk/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,8 @@ $ cdk deploy --hotswap [StackNames]
348348
This will attempt to perform a faster, short-circuit deployment if possible
349349
(for example, if you only changed the code of a Lambda function in your CDK app,
350350
but nothing else in your CDK code),
351-
skipping CloudFormation, and updating the affected resources directly.
351+
skipping CloudFormation, and updating the affected resources directly;
352+
this includes changes to resources in nested stacks.
352353
If the tool detects that the change does not support hotswapping,
353354
it will fall back and perform a full CloudFormation deployment,
354355
exactly like `cdk deploy` does without the `--hotswap` flag.

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

+5-103
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import * as path from 'path';
21
import * as cxapi from '@aws-cdk/cx-api';
32
import { AssetManifest } from 'cdk-assets';
4-
import * as fs from 'fs-extra';
53
import { Tag } from '../cdk-toolkit';
64
import { debug, warning } from '../logging';
75
import { publishAssets } from '../util/asset-publishing';
86
import { Mode } from './aws-auth/credentials';
97
import { ISDK } from './aws-auth/sdk';
108
import { SdkProvider } from './aws-auth/sdk-provider';
119
import { deployStack, DeployStackResult, destroyStack } from './deploy-stack';
12-
import { LazyListStackResources, ListStackResources } from './evaluate-cloudformation-template';
10+
import { loadCurrentTemplateWithNestedStacks, loadCurrentTemplate } from './nested-stack-helpers';
1311
import { ToolkitInfo } from './toolkit-info';
1412
import { CloudFormationStack, Template } from './util/cloudformation';
1513
import { StackActivityProgress } from './util/cloudformation/stack-activity-monitor';
@@ -283,21 +281,13 @@ export class CloudFormationDeployments {
283281

284282
public async readCurrentTemplateWithNestedStacks(rootStackArtifact: cxapi.CloudFormationStackArtifact): Promise<Template> {
285283
const sdk = await this.prepareSdkWithLookupOrDeployRole(rootStackArtifact);
286-
const deployedTemplate = await this.readCurrentTemplate(rootStackArtifact, sdk);
287-
await this.addNestedTemplatesToGeneratedAndDeployedStacks(rootStackArtifact, sdk, {
288-
generatedTemplate: rootStackArtifact.template,
289-
deployedTemplate: deployedTemplate,
290-
deployedStackName: rootStackArtifact.stackName,
291-
});
292-
return deployedTemplate;
284+
return (await loadCurrentTemplateWithNestedStacks(rootStackArtifact, sdk)).deployedTemplate;
293285
}
294286

295-
public async readCurrentTemplate(stackArtifact: cxapi.CloudFormationStackArtifact, sdk?: ISDK): Promise<Template> {
287+
public async readCurrentTemplate(stackArtifact: cxapi.CloudFormationStackArtifact): Promise<Template> {
296288
debug(`Reading existing template for stack ${stackArtifact.displayName}.`);
297-
if (!sdk) {
298-
sdk = await this.prepareSdkWithLookupOrDeployRole(stackArtifact);
299-
}
300-
return this.readCurrentStackTemplate(stackArtifact.stackName, sdk);
289+
const sdk = await this.prepareSdkWithLookupOrDeployRole(stackArtifact);
290+
return loadCurrentTemplate(stackArtifact, sdk);
301291
}
302292

303293
public async deployStack(options: DeployStackOptions): Promise<DeployStackResult> {
@@ -370,83 +360,6 @@ export class CloudFormationDeployments {
370360
return (await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading)).stackSdk;
371361
}
372362

373-
private async readCurrentStackTemplate(stackName: string, stackSdk: ISDK) : Promise<Template> {
374-
const cfn = stackSdk.cloudFormation();
375-
const stack = await CloudFormationStack.lookup(cfn, stackName);
376-
return stack.template();
377-
}
378-
379-
private async addNestedTemplatesToGeneratedAndDeployedStacks(
380-
rootStackArtifact: cxapi.CloudFormationStackArtifact,
381-
sdk: ISDK,
382-
parentTemplates: StackTemplates,
383-
): Promise<void> {
384-
const listStackResources = parentTemplates.deployedStackName ? new LazyListStackResources(sdk, parentTemplates.deployedStackName) : undefined;
385-
for (const [nestedStackLogicalId, generatedNestedStackResource] of Object.entries(parentTemplates.generatedTemplate.Resources ?? {})) {
386-
if (!this.isCdkManagedNestedStack(generatedNestedStackResource)) {
387-
continue;
388-
}
389-
390-
const assetPath = generatedNestedStackResource.Metadata['aws:asset:path'];
391-
const nestedStackTemplates = await this.getNestedStackTemplates(rootStackArtifact, assetPath, nestedStackLogicalId, listStackResources, sdk);
392-
393-
generatedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.generatedTemplate;
394-
395-
const deployedParentTemplate = parentTemplates.deployedTemplate;
396-
deployedParentTemplate.Resources = deployedParentTemplate.Resources ?? {};
397-
const deployedNestedStackResource = deployedParentTemplate.Resources[nestedStackLogicalId] ?? {};
398-
deployedParentTemplate.Resources[nestedStackLogicalId] = deployedNestedStackResource;
399-
deployedNestedStackResource.Type = deployedNestedStackResource.Type ?? 'AWS::CloudFormation::Stack';
400-
deployedNestedStackResource.Properties = deployedNestedStackResource.Properties ?? {};
401-
deployedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.deployedTemplate;
402-
403-
await this.addNestedTemplatesToGeneratedAndDeployedStacks(
404-
rootStackArtifact,
405-
sdk,
406-
nestedStackTemplates,
407-
);
408-
}
409-
}
410-
411-
private async getNestedStackTemplates(
412-
rootStackArtifact: cxapi.CloudFormationStackArtifact, nestedTemplateAssetPath: string, nestedStackLogicalId: string,
413-
listStackResources: ListStackResources | undefined, sdk: ISDK,
414-
): Promise<StackTemplates> {
415-
const nestedTemplatePath = path.join(rootStackArtifact.assembly.directory, nestedTemplateAssetPath);
416-
417-
// CFN generates the nested stack name in the form `ParentStackName-NestedStackLogicalID-SomeHashWeCan'tCompute,
418-
// the arn is of the form: arn:aws:cloudformation:region:123456789012:stack/NestedStackName/AnotherHashWeDon'tNeed
419-
// so we get the ARN and manually extract the name.
420-
const nestedStackArn = await this.getNestedStackArn(nestedStackLogicalId, listStackResources);
421-
const deployedStackName = nestedStackArn?.slice(nestedStackArn.indexOf('/') + 1, nestedStackArn.lastIndexOf('/'));
422-
423-
return {
424-
generatedTemplate: JSON.parse(fs.readFileSync(nestedTemplatePath, 'utf-8')),
425-
deployedTemplate: deployedStackName
426-
? await this.readCurrentStackTemplate(deployedStackName, sdk)
427-
: {},
428-
deployedStackName,
429-
};
430-
}
431-
432-
private async getNestedStackArn(
433-
nestedStackLogicalId: string, listStackResources?: ListStackResources,
434-
): Promise<string | undefined> {
435-
try {
436-
const stackResources = await listStackResources?.listStackResources();
437-
return stackResources?.find(sr => sr.LogicalResourceId === nestedStackLogicalId)?.PhysicalResourceId;
438-
} catch (e) {
439-
if (e.message.startsWith('Stack with id ') && e.message.endsWith(' does not exist')) {
440-
return;
441-
}
442-
throw e;
443-
}
444-
}
445-
446-
private isCdkManagedNestedStack(stackResource: any): stackResource is NestedStackResource {
447-
return stackResource.Type === 'AWS::CloudFormation::Stack' && stackResource.Metadata && stackResource.Metadata['aws:asset:path'];
448-
}
449-
450363
/**
451364
* Get the environment necessary for touching the given stack
452365
*
@@ -528,14 +441,3 @@ export class CloudFormationDeployments {
528441
function isAssetManifestArtifact(art: cxapi.CloudArtifact): art is cxapi.AssetManifestArtifact {
529442
return art instanceof cxapi.AssetManifestArtifact;
530443
}
531-
532-
interface StackTemplates {
533-
readonly generatedTemplate: any;
534-
readonly deployedTemplate: any;
535-
readonly deployedStackName: string | undefined;
536-
}
537-
538-
interface NestedStackResource {
539-
readonly Metadata: { 'aws:asset:path': string };
540-
readonly Properties: any;
541-
}

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

+23-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import * as cxapi from '@aws-cdk/cx-api';
21
import * as AWS from 'aws-sdk';
32
import { ISDK } from './aws-auth';
43

@@ -43,7 +42,7 @@ export interface ResourceDefinition {
4342
}
4443

4544
export interface EvaluateCloudFormationTemplateProps {
46-
readonly stackArtifact: cxapi.CloudFormationStackArtifact;
45+
readonly template: Template;
4746
readonly parameters: { [parameterName: string]: string };
4847
readonly account: string;
4948
readonly region: string;
@@ -54,8 +53,8 @@ export interface EvaluateCloudFormationTemplateProps {
5453

5554
export class EvaluateCloudFormationTemplate {
5655
private readonly stackResources: ListStackResources;
57-
private readonly template: { [section: string]: { [headings: string]: any } };
58-
private readonly context: { [k: string]: string };
56+
private readonly template: Template;
57+
private readonly context: { [k: string]: any };
5958
private readonly account: string;
6059
private readonly region: string;
6160
private readonly partition: string;
@@ -64,7 +63,7 @@ export class EvaluateCloudFormationTemplate {
6463

6564
constructor(props: EvaluateCloudFormationTemplateProps) {
6665
this.stackResources = props.listStackResources;
67-
this.template = props.stackArtifact.template;
66+
this.template = props.template;
6867
this.context = {
6968
'AWS::AccountId': props.account,
7069
'AWS::Region': props.region,
@@ -77,6 +76,23 @@ export class EvaluateCloudFormationTemplate {
7776
this.urlSuffix = props.urlSuffix;
7877
}
7978

79+
// clones current EvaluateCloudFormationTemplate object, but updates the stack name
80+
public createNestedEvaluateCloudFormationTemplate(
81+
listNestedStackResources: ListStackResources,
82+
nestedTemplate: Template,
83+
nestedStackParameters: { [parameterName: string]: any },
84+
) {
85+
return new EvaluateCloudFormationTemplate({
86+
template: nestedTemplate,
87+
parameters: nestedStackParameters,
88+
account: this.account,
89+
region: this.region,
90+
partition: this.partition,
91+
urlSuffix: this.urlSuffix,
92+
listStackResources: listNestedStackResources,
93+
});
94+
}
95+
8096
public async establishResourcePhysicalName(logicalId: string, physicalNameInCfnTemplate: any): Promise<string | undefined> {
8197
if (physicalNameInCfnTemplate != null) {
8298
try {
@@ -309,6 +325,8 @@ export class EvaluateCloudFormationTemplate {
309325
}
310326
}
311327

328+
export type Template = { [section: string]: { [headings: string]: any } };
329+
312330
interface ArnParts {
313331
readonly partition: string;
314332
readonly service: string;

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

+54-6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isHotswappableEcsServiceChange } from './hotswap/ecs-services';
1212
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
1313
import { isHotswappableS3BucketDeploymentChange } from './hotswap/s3-bucket-deployments';
1414
import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines';
15+
import { loadCurrentTemplateWithNestedStacks, NestedStackNames } from './nested-stack-helpers';
1516
import { CloudFormationStack } from './util/cloudformation';
1617

1718
/**
@@ -35,7 +36,7 @@ export async function tryHotswapDeployment(
3536
// We fetch it lazily, to save a service call, in case all hotswapped resources have their physical names set.
3637
const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName);
3738
const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({
38-
stackArtifact,
39+
template: stackArtifact.template,
3940
parameters: assetParams,
4041
account: resolvedEnv.account,
4142
region: resolvedEnv.region,
@@ -44,9 +45,12 @@ export async function tryHotswapDeployment(
4445
listStackResources,
4546
});
4647

47-
const currentTemplate = await cloudFormationStack.template();
48-
const stackChanges = cfn_diff.diffTemplate(currentTemplate, stackArtifact.template);
49-
const hotswappableChanges = await findAllHotswappableChanges(stackChanges, evaluateCfnTemplate);
48+
const currentTemplate = await loadCurrentTemplateWithNestedStacks(stackArtifact, sdk);
49+
const stackChanges = cfn_diff.diffTemplate(currentTemplate.deployedTemplate, stackArtifact.template);
50+
const hotswappableChanges = await findAllHotswappableChanges(
51+
stackChanges, evaluateCfnTemplate, sdk, currentTemplate.nestedStackNames,
52+
);
53+
5054
if (!hotswappableChanges) {
5155
// this means there were changes to the template that cannot be short-circuited
5256
return undefined;
@@ -59,14 +63,28 @@ export async function tryHotswapDeployment(
5963
}
6064

6165
async function findAllHotswappableChanges(
62-
stackChanges: cfn_diff.TemplateDiff, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
66+
stackChanges: cfn_diff.TemplateDiff,
67+
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
68+
sdk: ISDK,
69+
nestedStackNames: { [nestedStackName: string]: NestedStackNames },
6370
): Promise<HotswapOperation[] | undefined> {
6471
const resourceDifferences = getStackResourceDifferences(stackChanges);
6572

6673
let foundNonHotswappableChange = false;
6774
const promises: Array<Array<Promise<ChangeHotswapResult>>> = [];
75+
const hotswappableResources = new Array<HotswapOperation>();
76+
6877
// gather the results of the detector functions
6978
for (const [logicalId, change] of Object.entries(resourceDifferences)) {
79+
if (change.newValue?.Type === 'AWS::CloudFormation::Stack' && change.oldValue?.Type === 'AWS::CloudFormation::Stack') {
80+
const nestedHotswappableResources = await findNestedHotswappableChanges(logicalId, change, nestedStackNames, evaluateCfnTemplate, sdk);
81+
if (!nestedHotswappableResources) {
82+
return undefined;
83+
}
84+
hotswappableResources.push(...nestedHotswappableResources);
85+
continue;
86+
}
87+
7088
const resourceHotswapEvaluation = isCandidateForHotswapping(change);
7189

7290
if (resourceHotswapEvaluation === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {
@@ -92,7 +110,6 @@ async function findAllHotswappableChanges(
92110
changesDetectionResults.push(hotswapDetectionResults);
93111
}
94112

95-
const hotswappableResources = new Array<HotswapOperation>();
96113
for (const hotswapDetectionResults of changesDetectionResults) {
97114
const perChangeHotswappableResources = new Array<HotswapOperation>();
98115

@@ -165,6 +182,32 @@ function filterDict<T>(dict: { [key: string]: T }, func: (t: T) => boolean): { [
165182
}, {} as { [key: string]: T });
166183
}
167184

185+
/** Finds any hotswappable changes in all nested stacks. */
186+
async function findNestedHotswappableChanges(
187+
logicalId: string,
188+
change: cfn_diff.ResourceDifference,
189+
nestedStackNames: { [nestedStackName: string]: NestedStackNames },
190+
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
191+
sdk: ISDK,
192+
): Promise<HotswapOperation[] | undefined> {
193+
const nestedStackName = nestedStackNames[logicalId].nestedStackPhysicalName;
194+
// the stack name could not be found in CFN, so this is a newly created nested stack
195+
if (!nestedStackName) {
196+
return undefined;
197+
}
198+
199+
const nestedStackParameters = await evaluateCfnTemplate.evaluateCfnExpression(change.newValue?.Properties?.Parameters);
200+
const evaluateNestedCfnTemplate = evaluateCfnTemplate.createNestedEvaluateCloudFormationTemplate(
201+
new LazyListStackResources(sdk, nestedStackName), change.newValue?.Properties?.NestedTemplate, nestedStackParameters,
202+
);
203+
204+
const nestedDiff = cfn_diff.diffTemplate(
205+
change.oldValue?.Properties?.NestedTemplate, change.newValue?.Properties?.NestedTemplate,
206+
);
207+
208+
return findAllHotswappableChanges(nestedDiff, evaluateNestedCfnTemplate, sdk, nestedStackNames[logicalId].nestedChildStackNames);
209+
}
210+
168211
/** Returns 'true' if a pair of changes is for the same resource. */
169212
function changesAreForSameResource(oldChange: cfn_diff.ResourceDifference, newChange: cfn_diff.ResourceDifference): boolean {
170213
return oldChange.oldResourceType === newChange.newResourceType &&
@@ -201,6 +244,11 @@ function isCandidateForHotswapping(change: cfn_diff.ResourceDifference): Hotswap
201244
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
202245
}
203246

247+
// a resource has had its type changed
248+
if (change.newValue.Type !== change.oldValue.Type) {
249+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
250+
}
251+
204252
// Ignore Metadata changes
205253
if (change.newValue.Type === 'AWS::CDK::Metadata') {
206254
return ChangeHotswapImpact.IRRELEVANT;

packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export async function findCloudWatchLogGroups(
5656

5757
const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName);
5858
const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({
59-
stackArtifact,
59+
template: stackArtifact.template,
6060
parameters: {},
6161
account: resolvedEnv.account,
6262
region: resolvedEnv.region,

0 commit comments

Comments
 (0)