Skip to content

Commit a3b405a

Browse files
authored
refactor(cli): non hotswappable changes with structured data (#255)
Refactor `NonHotswappableChange` to contain consistent, well-structured data. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 814c45d commit a3b405a

File tree

3 files changed

+225
-100
lines changed

3 files changed

+225
-100
lines changed

packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap.ts

+95
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export interface ResourceChange {
5454
* The changes made to the resource properties
5555
*/
5656
readonly propertyUpdates: Record<string, PropertyDifference<unknown>>;
57+
/**
58+
* Resource metadata attached to the logical id from the cloud assembly
59+
*
60+
* This is only present if the resource is present in the current Cloud Assembly,
61+
* i.e. resource deletions will not have metadata.
62+
*/
63+
readonly metadata?: ResourceMetadata;
5764
}
5865

5966
/**
@@ -109,6 +116,66 @@ export enum NonHotswappableReason {
109116
NESTED_STACK_CREATION = 'nested-stack-creation',
110117
}
111118

119+
export interface RejectionSubject {
120+
/**
121+
* The type of the rejection subject, e.g. Resource or Output
122+
*/
123+
readonly type: string;
124+
125+
/**
126+
* The logical ID of the change that is not hotswappable
127+
*/
128+
readonly logicalId: string;
129+
/**
130+
* Resource metadata attached to the logical id from the cloud assembly
131+
*
132+
* This is only present if the resource is present in the current Cloud Assembly,
133+
* i.e. resource deletions will not have metadata.
134+
*/
135+
readonly metadata?: ResourceMetadata;
136+
}
137+
138+
export interface ResourceSubject extends RejectionSubject {
139+
/**
140+
* A rejected resource
141+
*/
142+
readonly type: 'Resource';
143+
/**
144+
* The type of the rejected resource
145+
*/
146+
readonly resourceType: string;
147+
/**
148+
* The list of properties that are cause for the rejection
149+
*/
150+
readonly rejectedProperties?: string[];
151+
}
152+
153+
export interface OutputSubject extends RejectionSubject {
154+
/**
155+
* A rejected output
156+
*/
157+
readonly type: 'Output';
158+
}
159+
160+
/**
161+
* A change that can not be hotswapped
162+
*/
163+
export interface NonHotswappableChange {
164+
/**
165+
* The subject of the change that was rejected
166+
*/
167+
readonly subject: ResourceSubject | OutputSubject;
168+
/**
169+
* Why was this change was deemed non-hotswappable
170+
*/
171+
readonly reason: NonHotswappableReason;
172+
/**
173+
* Tells the user exactly why this change was deemed non-hotswappable and what its logical ID is.
174+
* If not specified, `displayReason` default to state that the properties listed in `rejectedChanges` are not hotswappable.
175+
*/
176+
readonly description: string;
177+
}
178+
112179
/**
113180
* Information about a hotswap deployment
114181
*/
@@ -123,3 +190,31 @@ export interface HotswapDeployment {
123190
*/
124191
readonly mode: 'hotswap-only' | 'fall-back';
125192
}
193+
194+
/**
195+
* The result of an attempted hotswap deployment
196+
*/
197+
export interface HotswapResult {
198+
/**
199+
* The stack that was hotswapped
200+
*/
201+
readonly stack: cxapi.CloudFormationStackArtifact;
202+
/**
203+
* The mode the hotswap deployment was initiated with.
204+
*/
205+
readonly mode: 'hotswap-only' | 'fall-back';
206+
/**
207+
* Whether hotswapping happened or not.
208+
*
209+
* `false` indicates that the deployment could not be hotswapped and full deployment may be attempted as fallback.
210+
*/
211+
readonly hotswapped: boolean;
212+
/**
213+
* The changes that were deemed hotswappable
214+
*/
215+
readonly hotswappableChanges: HotswappableChange[];
216+
/**
217+
* The changes that were deemed not hotswappable
218+
*/
219+
readonly nonHotswappableChanges: NonHotswappableChange[];
220+
}

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

+105-45
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as cfn_diff from '@aws-cdk/cloudformation-diff';
33
import type * as cxapi from '@aws-cdk/cx-api';
44
import type { WaiterResult } from '@smithy/util-waiter';
55
import * as chalk from 'chalk';
6-
import type { AffectedResource, ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads';
6+
import type { AffectedResource, HotswapResult, ResourceSubject, ResourceChange, NonHotswappableChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads';
77
import { NonHotswappableReason } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads';
88
import type { IMessageSpan, IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private';
99
import { IO, SPAN } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private';
@@ -21,7 +21,6 @@ import type {
2121
HotswapOperation,
2222
RejectedChange,
2323
HotswapPropertyOverrides,
24-
HotswapResult,
2524
} from '../hotswap/common';
2625
import {
2726
ICON,
@@ -156,22 +155,24 @@ async function hotswapDeployment(
156155
});
157156

158157
const stackChanges = cfn_diff.fullDiff(currentTemplate.deployedRootTemplate, stack.template);
159-
const { hotswappable: hotswapOperations, nonHotswappable: nonHotswappableChanges } = await classifyResourceChanges(
158+
const { hotswappable, nonHotswappable } = await classifyResourceChanges(
160159
stackChanges,
161160
evaluateCfnTemplate,
162161
sdk,
163162
currentTemplate.nestedStacks, hotswapPropertyOverrides,
164163
);
165164

166-
await logNonHotswappableChanges(ioSpan, nonHotswappableChanges, hotswapMode);
165+
await logNonHotswappableChanges(ioSpan, nonHotswappable, hotswapMode);
167166

168-
const hotswappableChanges = hotswapOperations.map(o => o.change);
167+
const hotswappableChanges = hotswappable.map(o => o.change);
168+
const nonHotswappableChanges = nonHotswappable.map(n => n.change);
169169

170170
// preserve classic hotswap behavior
171171
if (hotswapMode === 'fall-back') {
172172
if (nonHotswappableChanges.length > 0) {
173173
return {
174174
stack,
175+
mode: hotswapMode,
175176
hotswapped: false,
176177
hotswappableChanges,
177178
nonHotswappableChanges,
@@ -180,10 +181,11 @@ async function hotswapDeployment(
180181
}
181182

182183
// apply the short-circuitable changes
183-
await applyAllHotswappableChanges(sdk, ioSpan, hotswapOperations);
184+
await applyAllHotswappableChanges(sdk, ioSpan, hotswappable);
184185

185186
return {
186187
stack,
188+
mode: hotswapMode,
187189
hotswapped: true,
188190
hotswappableChanges,
189191
nonHotswappableChanges,
@@ -214,10 +216,15 @@ async function classifyResourceChanges(
214216
for (const logicalId of Object.keys(stackChanges.outputs.changes)) {
215217
nonHotswappableResources.push({
216218
hotswappable: false,
217-
reason: NonHotswappableReason.OUTPUT,
218-
description: 'output was changed',
219-
logicalId,
220-
resourceType: 'Stack Output',
219+
change: {
220+
reason: NonHotswappableReason.OUTPUT,
221+
description: 'output was changed',
222+
subject: {
223+
type: 'Output',
224+
logicalId,
225+
metadata: evaluateCfnTemplate.metadataFor(logicalId),
226+
},
227+
},
221228
});
222229
}
223230
// gather the results of the detector functions
@@ -237,7 +244,7 @@ async function classifyResourceChanges(
237244
continue;
238245
}
239246

240-
const hotswappableChangeCandidate = isCandidateForHotswapping(change, logicalId);
247+
const hotswappableChangeCandidate = isCandidateForHotswapping(logicalId, change, evaluateCfnTemplate);
241248
// we don't need to run this through the detector functions, we can already judge this
242249
if ('hotswappable' in hotswappableChangeCandidate) {
243250
if (!hotswappableChangeCandidate.hotswappable) {
@@ -348,10 +355,16 @@ async function findNestedHotswappableChanges(
348355
nonHotswappable: [
349356
{
350357
hotswappable: false,
351-
logicalId,
352-
reason: NonHotswappableReason.NESTED_STACK_CREATION,
353-
description: `physical name for AWS::CloudFormation::Stack '${logicalId}' could not be found in CloudFormation, so this is a newly created nested stack and cannot be hotswapped`,
354-
resourceType: 'AWS::CloudFormation::Stack',
358+
change: {
359+
reason: NonHotswappableReason.NESTED_STACK_CREATION,
360+
description: 'newly created nested stacks cannot be hotswapped',
361+
subject: {
362+
type: 'Resource',
363+
logicalId,
364+
resourceType: 'AWS::CloudFormation::Stack',
365+
metadata: evaluateCfnTemplate.metadataFor(logicalId),
366+
},
367+
},
355368
},
356369
],
357370
};
@@ -414,36 +427,56 @@ function makeRenameDifference(
414427
* Returns a `NonHotswappableChange` if the change is not hotswappable
415428
*/
416429
function isCandidateForHotswapping(
417-
change: cfn_diff.ResourceDifference,
418430
logicalId: string,
431+
change: cfn_diff.ResourceDifference,
432+
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
419433
): RejectedChange | ResourceChange {
420434
// a resource has been removed OR a resource has been added; we can't short-circuit that change
421435
if (!change.oldValue) {
422436
return {
423437
hotswappable: false,
424-
resourceType: change.newValue!.Type,
425-
logicalId,
426-
reason: NonHotswappableReason.RESOURCE_CREATION,
427-
description: `resource '${logicalId}' was created by this deployment`,
438+
change: {
439+
reason: NonHotswappableReason.RESOURCE_CREATION,
440+
description: `resource '${logicalId}' was created by this deployment`,
441+
subject: {
442+
type: 'Resource',
443+
logicalId,
444+
resourceType: change.newValue!.Type,
445+
metadata: evaluateCfnTemplate.metadataFor(logicalId),
446+
},
447+
},
428448
};
429449
} else if (!change.newValue) {
430450
return {
431451
hotswappable: false,
432-
resourceType: change.oldValue!.Type,
433452
logicalId,
434-
reason: NonHotswappableReason.RESOURCE_DELETION,
435-
description: `resource '${logicalId}' was destroyed by this deployment`,
453+
change: {
454+
reason: NonHotswappableReason.RESOURCE_DELETION,
455+
description: `resource '${logicalId}' was destroyed by this deployment`,
456+
subject: {
457+
type: 'Resource',
458+
logicalId,
459+
resourceType: change.oldValue.Type,
460+
metadata: evaluateCfnTemplate.metadataFor(logicalId),
461+
},
462+
},
436463
};
437464
}
438465

439466
// a resource has had its type changed
440-
if (change.newValue?.Type !== change.oldValue?.Type) {
467+
if (change.newValue.Type !== change.oldValue.Type) {
441468
return {
442469
hotswappable: false,
443-
resourceType: change.newValue?.Type,
444-
logicalId,
445-
reason: NonHotswappableReason.RESOURCE_TYPE_CHANGED,
446-
description: `resource '${logicalId}' had its type changed from '${change.oldValue?.Type}' to '${change.newValue?.Type}'`,
470+
change: {
471+
reason: NonHotswappableReason.RESOURCE_TYPE_CHANGED,
472+
description: `resource '${logicalId}' had its type changed from '${change.oldValue?.Type}' to '${change.newValue?.Type}'`,
473+
subject: {
474+
type: 'Resource',
475+
logicalId,
476+
resourceType: change.newValue.Type,
477+
metadata: evaluateCfnTemplate.metadataFor(logicalId),
478+
},
479+
},
447480
};
448481
}
449482

@@ -452,6 +485,7 @@ function isCandidateForHotswapping(
452485
oldValue: change.oldValue,
453486
newValue: change.newValue,
454487
propertyUpdates: change.propertyUpdates,
488+
metadata: evaluateCfnTemplate.metadataFor(logicalId),
455489
};
456490
}
457491

@@ -547,25 +581,51 @@ async function logNonHotswappableChanges(
547581
messages.push(format('%s %s', chalk.red('⚠️'), chalk.red('The following non-hotswappable changes were found:')));
548582
}
549583

550-
for (const change of nonHotswappableChanges) {
551-
if (change.rejectedProperties?.length) {
552-
messages.push(format(
553-
' logicalID: %s, type: %s, rejected changes: %s, reason: %s',
554-
chalk.bold(change.logicalId),
555-
chalk.bold(change.resourceType),
556-
chalk.bold(change.rejectedProperties),
557-
chalk.red(change.description),
558-
));
559-
} else {
560-
messages.push(format(
561-
' logicalID: %s, type: %s, reason: %s',
562-
chalk.bold(change.logicalId),
563-
chalk.bold(change.resourceType),
564-
chalk.red(change.description),
565-
));
566-
}
584+
for (const rejection of nonHotswappableChanges) {
585+
messages.push(' ' + nonHotswappableChangeMessage(rejection.change));
567586
}
568587
messages.push(''); // newline
569588

570589
await ioSpan.notify(IO.DEFAULT_TOOLKIT_INFO.msg(messages.join('\n')));
571590
}
591+
592+
/**
593+
* Formats a NonHotswappableChange
594+
*/
595+
function nonHotswappableChangeMessage(change: NonHotswappableChange): string {
596+
const subject = change.subject;
597+
const reason = change.description ?? change.reason;
598+
599+
switch (subject.type) {
600+
case 'Output':
601+
return format(
602+
'output: %s, reason: %s',
603+
chalk.bold(subject.logicalId),
604+
chalk.red(reason),
605+
);
606+
case 'Resource':
607+
return nonHotswappableResourceMessage(subject, reason);
608+
}
609+
}
610+
611+
/**
612+
* Formats a non-hotswappable resource subject
613+
*/
614+
function nonHotswappableResourceMessage(subject: ResourceSubject, reason: string): string {
615+
if (subject.rejectedProperties?.length) {
616+
return format(
617+
'resource: %s, type: %s, rejected changes: %s, reason: %s',
618+
chalk.bold(subject.logicalId),
619+
chalk.bold(subject.resourceType),
620+
chalk.bold(subject.rejectedProperties),
621+
chalk.red(reason),
622+
);
623+
}
624+
625+
return format(
626+
'resource: %s, type: %s, reason: %s',
627+
chalk.bold(subject.logicalId),
628+
chalk.bold(subject.resourceType),
629+
chalk.red(reason),
630+
);
631+
}

0 commit comments

Comments
 (0)