Skip to content

Commit a83bd89

Browse files
committed
refactor(cli): non hotswappable changes with structured data
1 parent 0cce2f5 commit a83bd89

File tree

3 files changed

+199
-97
lines changed

3 files changed

+199
-97
lines changed

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,59 @@ export enum NonHotswappableReason {
109109
NESTED_STACK_CREATION = 'nested-stack-creation',
110110
}
111111

112+
export interface RejectionSubject {
113+
/**
114+
* The type of the rejection subject, e.g. Resource or Output
115+
*/
116+
readonly type: string;
117+
118+
/**
119+
* The logical ID of the change that is not hotswappable
120+
*/
121+
readonly logicalId: string;
122+
}
123+
124+
export interface ResourceSubject extends RejectionSubject {
125+
/**
126+
* A rejected resource
127+
*/
128+
readonly type: 'Resource';
129+
/**
130+
* The type of the rejected resource
131+
*/
132+
readonly resourceType: string;
133+
/**
134+
* The list of properties that are cause for the rejection
135+
*/
136+
readonly rejectedProperties?: string[];
137+
}
138+
139+
export interface OutputSubject extends RejectionSubject {
140+
/**
141+
* A rejected output
142+
*/
143+
readonly type: 'Output';
144+
}
145+
146+
/**
147+
* A change that can not be hotswapped
148+
*/
149+
export interface NonHotswappableChange {
150+
/**
151+
* The subject of the change that was rejected
152+
*/
153+
readonly subject: ResourceSubject | OutputSubject;
154+
/**
155+
* Why was this change was deemed non-hotswappable
156+
*/
157+
readonly reason: NonHotswappableReason;
158+
/**
159+
* Tells the user exactly why this change was deemed non-hotswappable and what its logical ID is.
160+
* If not specified, `displayReason` default to state that the properties listed in `rejectedChanges` are not hotswappable.
161+
*/
162+
readonly description: string;
163+
}
164+
112165
/**
113166
* Information about a hotswap deployment
114167
*/
@@ -123,3 +176,31 @@ export interface HotswapDeployment {
123176
*/
124177
readonly mode: 'hotswap-only' | 'fall-back';
125178
}
179+
180+
/**
181+
* The result of an attempted hotswap deployment
182+
*/
183+
export interface HotswapResult {
184+
/**
185+
* The stack that was hotswapped
186+
*/
187+
readonly stack: cxapi.CloudFormationStackArtifact;
188+
/**
189+
* The mode the hotswap deployment was initiated with.
190+
*/
191+
readonly mode: 'hotswap-only' | 'fall-back';
192+
/**
193+
* Whether hotswapping happened or not.
194+
*
195+
* `false` indicates that the deployment could not be hotswapped and full deployment may be attempted as fallback.
196+
*/
197+
readonly hotswapped: boolean;
198+
/**
199+
* The changes that were deemed hotswappable
200+
*/
201+
readonly hotswappableChanges: HotswappableChange[];
202+
/**
203+
* The changes that were deemed not hotswappable
204+
*/
205+
readonly nonHotswappableChanges: NonHotswappableChange[];
206+
}

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

Lines changed: 95 additions & 42 deletions
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,14 @@ 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+
},
226+
},
221227
});
222228
}
223229
// gather the results of the detector functions
@@ -348,10 +354,15 @@ async function findNestedHotswappableChanges(
348354
nonHotswappable: [
349355
{
350356
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',
357+
change: {
358+
reason: NonHotswappableReason.NESTED_STACK_CREATION,
359+
description: 'newly created nested stacks cannot be hotswapped',
360+
subject: {
361+
type: 'Resource',
362+
resourceType: 'AWS::CloudFormation::Stack',
363+
logicalId,
364+
},
365+
},
355366
},
356367
],
357368
};
@@ -421,29 +432,45 @@ function isCandidateForHotswapping(
421432
if (!change.oldValue) {
422433
return {
423434
hotswappable: false,
424-
resourceType: change.newValue!.Type,
425-
logicalId,
426-
reason: NonHotswappableReason.RESOURCE_CREATION,
427-
description: `resource '${logicalId}' was created by this deployment`,
435+
change: {
436+
reason: NonHotswappableReason.RESOURCE_CREATION,
437+
description: `resource '${logicalId}' was created by this deployment`,
438+
subject: {
439+
type: 'Resource',
440+
resourceType: change.newValue!.Type,
441+
logicalId,
442+
},
443+
},
428444
};
429445
} else if (!change.newValue) {
430446
return {
431447
hotswappable: false,
432-
resourceType: change.oldValue!.Type,
433448
logicalId,
434-
reason: NonHotswappableReason.RESOURCE_DELETION,
435-
description: `resource '${logicalId}' was destroyed by this deployment`,
449+
change: {
450+
reason: NonHotswappableReason.RESOURCE_DELETION,
451+
description: `resource '${logicalId}' was destroyed by this deployment`,
452+
subject: {
453+
type: 'Resource',
454+
resourceType: change.oldValue?.Type,
455+
logicalId,
456+
},
457+
},
436458
};
437459
}
438460

439461
// a resource has had its type changed
440462
if (change.newValue?.Type !== change.oldValue?.Type) {
441463
return {
442464
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}'`,
465+
change: {
466+
reason: NonHotswappableReason.RESOURCE_TYPE_CHANGED,
467+
description: `resource '${logicalId}' had its type changed from '${change.oldValue?.Type}' to '${change.newValue?.Type}'`,
468+
subject: {
469+
type: 'Resource',
470+
resourceType: change.newValue?.Type,
471+
logicalId,
472+
},
473+
},
447474
};
448475
}
449476

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

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-
}
577+
for (const rejection of nonHotswappableChanges) {
578+
messages.push(' ' + nonHotswappableChangeMessage(rejection.change));
567579
}
568580
messages.push(''); // newline
569581

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

0 commit comments

Comments
 (0)