/
hotswap-deployments.ts
267 lines (240 loc) · 11.8 KB
/
hotswap-deployments.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
import * as cfn_diff from '@aws-cdk/cloudformation-diff';
import * as cxapi from '@aws-cdk/cx-api';
import { CloudFormation } from 'aws-sdk';
import * as colors from 'colors/safe';
import { print } from '../logging';
import { ISDK, Mode, SdkProvider } from './aws-auth';
import { DeployStackResult } from './deploy-stack';
import { isHotswappableCodeBuildProjectChange } from './hotswap/code-build-projects';
import { ICON, ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, ListStackResources } from './hotswap/common';
import { isHotswappableEcsServiceChange } from './hotswap/ecs-services';
import { EvaluateCloudFormationTemplate } from './hotswap/evaluate-cloudformation-template';
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { isHotswappableS3BucketDeploymentChange } from './hotswap/s3-bucket-deployments';
import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines';
import { CloudFormationStack } from './util/cloudformation';
/**
* Perform a hotswap deployment,
* short-circuiting CloudFormation if possible.
* If it's not possible to short-circuit the deployment
* (because the CDK Stack contains changes that cannot be deployed without CloudFormation),
* returns `undefined`.
*/
export async function tryHotswapDeployment(
sdkProvider: SdkProvider, assetParams: { [key: string]: string },
cloudFormationStack: CloudFormationStack, stackArtifact: cxapi.CloudFormationStackArtifact,
): Promise<DeployStackResult | undefined> {
// resolve the environment, so we can substitute things like AWS::Region in CFN expressions
const resolvedEnv = await sdkProvider.resolveEnvironment(stackArtifact.environment);
// create a new SDK using the CLI credentials, because the default one will not work for new-style synthesis -
// it assumes the bootstrap deploy Role, which doesn't have permissions to update Lambda functions
const sdk = await sdkProvider.forEnvironment(resolvedEnv, Mode.ForWriting);
// The current resources of the Stack.
// We need them to figure out the physical name of a resource in case it wasn't specified by the user.
// We fetch it lazily, to save a service call, in case all hotswapped resources have their physical names set.
const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName);
const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({
stackArtifact,
parameters: assetParams,
account: resolvedEnv.account,
region: resolvedEnv.region,
partition: (await sdk.currentAccount()).partition,
urlSuffix: sdk.getEndpointSuffix,
listStackResources,
});
const currentTemplate = await cloudFormationStack.template();
const stackChanges = cfn_diff.diffTemplate(currentTemplate, stackArtifact.template);
const hotswappableChanges = await findAllHotswappableChanges(stackChanges, evaluateCfnTemplate);
if (!hotswappableChanges) {
// this means there were changes to the template that cannot be short-circuited
return undefined;
}
// apply the short-circuitable changes
await applyAllHotswappableChanges(sdk, hotswappableChanges);
return { noOp: hotswappableChanges.length === 0, stackArn: cloudFormationStack.stackId, outputs: cloudFormationStack.outputs, stackArtifact };
}
async function findAllHotswappableChanges(
stackChanges: cfn_diff.TemplateDiff, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<HotswapOperation[] | undefined> {
const resourceDifferences = getStackResourceDifferences(stackChanges);
let foundNonHotswappableChange = false;
const promises: Array<Array<Promise<ChangeHotswapResult>>> = [];
// gather the results of the detector functions
for (const [logicalId, change] of Object.entries(resourceDifferences)) {
const resourceHotswapEvaluation = isCandidateForHotswapping(change);
if (resourceHotswapEvaluation === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) {
foundNonHotswappableChange = true;
} else if (resourceHotswapEvaluation === ChangeHotswapImpact.IRRELEVANT) {
// empty 'if' just for flow-aware typing to kick in...
} else {
promises.push([
isHotswappableLambdaFunctionChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableS3BucketDeploymentChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableCodeBuildProjectChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
]);
}
}
// resolve all detector results
const changesDetectionResults: Array<Array<ChangeHotswapResult>> = [];
for (const detectorResultPromises of promises) {
const hotswapDetectionResults = await Promise.all(detectorResultPromises);
changesDetectionResults.push(hotswapDetectionResults);
}
const hotswappableResources = new Array<HotswapOperation>();
for (const hotswapDetectionResults of changesDetectionResults) {
const perChangeHotswappableResources = new Array<HotswapOperation>();
for (const result of hotswapDetectionResults) {
if (typeof result !== 'string') {
perChangeHotswappableResources.push(result);
}
}
// if we found any hotswappable changes, return now
if (perChangeHotswappableResources.length > 0) {
hotswappableResources.push(...perChangeHotswappableResources);
continue;
}
// no hotswappable changes found, so at least one IRRELEVANT means we can ignore this change;
// otherwise, all answers are REQUIRES_FULL_DEPLOYMENT, so this means we can't hotswap this change,
// and have to do a full deployment instead
if (!hotswapDetectionResults.some(hdr => hdr === ChangeHotswapImpact.IRRELEVANT)) {
foundNonHotswappableChange = true;
}
}
return foundNonHotswappableChange ? undefined : hotswappableResources;
}
/**
* Returns all changes to resources in the given Stack.
*
* @param stackChanges the collection of all changes to a given Stack
*/
function getStackResourceDifferences(stackChanges: cfn_diff.TemplateDiff): { [logicalId: string]: cfn_diff.ResourceDifference } {
// we need to collapse logical ID rename changes into one change,
// as they are represented in stackChanges as a pair of two changes: one addition and one removal
const allResourceChanges: { [logId: string]: cfn_diff.ResourceDifference } = stackChanges.resources.changes;
const allRemovalChanges = filterDict(allResourceChanges, resChange => resChange.isRemoval);
const allNonRemovalChanges = filterDict(allResourceChanges, resChange => !resChange.isRemoval);
for (const [logId, nonRemovalChange] of Object.entries(allNonRemovalChanges)) {
if (nonRemovalChange.isAddition) {
const addChange = nonRemovalChange;
// search for an identical removal change
const identicalRemovalChange = Object.entries(allRemovalChanges).find(([_, remChange]) => {
return changesAreForSameResource(remChange, addChange);
});
// if we found one, then this means this is a rename change
if (identicalRemovalChange) {
const [removedLogId, removedResourceChange] = identicalRemovalChange;
allNonRemovalChanges[logId] = makeRenameDifference(removedResourceChange, addChange);
// delete the removal change that forms the rename pair
delete allRemovalChanges[removedLogId];
}
}
}
// the final result are all of the remaining removal changes,
// plus all of the non-removal changes
// (we saved the rename changes in that object already)
return {
...allRemovalChanges,
...allNonRemovalChanges,
};
}
/** Filters an object with string keys based on whether the callback returns 'true' for the given value in the object. */
function filterDict<T>(dict: { [key: string]: T }, func: (t: T) => boolean): { [key: string]: T } {
return Object.entries(dict).reduce((acc, [key, t]) => {
if (func(t)) {
acc[key] = t;
}
return acc;
}, {} as { [key: string]: T });
}
/** Returns 'true' if a pair of changes is for the same resource. */
function changesAreForSameResource(oldChange: cfn_diff.ResourceDifference, newChange: cfn_diff.ResourceDifference): boolean {
return oldChange.oldResourceType === newChange.newResourceType &&
// this isn't great, but I don't want to bring in something like underscore just for this comparison
JSON.stringify(oldChange.oldProperties) === JSON.stringify(newChange.newProperties);
}
function makeRenameDifference(
remChange: cfn_diff.ResourceDifference,
addChange: cfn_diff.ResourceDifference,
): cfn_diff.ResourceDifference {
return new cfn_diff.ResourceDifference(
// we have to fill in the old value, because otherwise this will be classified as a non-hotswappable change
remChange.oldValue,
addChange.newValue,
{
resourceType: {
oldType: remChange.oldResourceType,
newType: addChange.newResourceType,
},
propertyDiffs: (addChange as any).propertyDiffs,
otherDiffs: (addChange as any).otherDiffs,
},
);
}
/**
* returns `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` if a resource was deleted, or a change that we cannot short-circuit occured.
* Returns `ChangeHotswapImpact.IRRELEVANT` if a change that does not impact shortcircuiting occured, such as a metadata change.
*/
function isCandidateForHotswapping(change: cfn_diff.ResourceDifference): HotswappableChangeCandidate | ChangeHotswapImpact {
// a resource has been removed OR a resource has been added; we can't short-circuit that change
if (!change.newValue || !change.oldValue) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
// Ignore Metadata changes
if (change.newValue.Type === 'AWS::CDK::Metadata') {
return ChangeHotswapImpact.IRRELEVANT;
}
return {
newValue: change.newValue,
propertyUpdates: change.propertyUpdates,
};
}
async function applyAllHotswappableChanges(
sdk: ISDK, hotswappableChanges: HotswapOperation[],
): Promise<void[]> {
print(`\n${ICON} hotswapping resources:`);
return Promise.all(hotswappableChanges.map(hotswapOperation => {
return applyHotswappableChange(sdk, hotswapOperation);
}));
}
async function applyHotswappableChange(sdk: ISDK, hotswapOperation: HotswapOperation): Promise<any> {
// note the type of service that was successfully hotswapped in the User-Agent
const customUserAgent = `cdk-hotswap/success-${hotswapOperation.service}`;
sdk.appendCustomUserAgent(customUserAgent);
try {
for (const name of hotswapOperation.resourceNames) {
print(` ${ICON} %s`, colors.bold(name));
}
return await hotswapOperation.apply(sdk);
} finally {
for (const name of hotswapOperation.resourceNames) {
print(`${ICON} %s %s`, colors.bold(name), colors.green('hotswapped!'));
}
sdk.removeCustomUserAgent(customUserAgent);
}
}
class LazyListStackResources implements ListStackResources {
private stackResources: CloudFormation.StackResourceSummary[] | undefined;
constructor(private readonly sdk: ISDK, private readonly stackName: string) {
}
async listStackResources(): Promise<CloudFormation.StackResourceSummary[]> {
if (this.stackResources === undefined) {
this.stackResources = await this.getStackResources();
}
return this.stackResources;
}
private async getStackResources(): Promise<CloudFormation.StackResourceSummary[]> {
const ret = new Array<CloudFormation.StackResourceSummary>();
let nextToken: string | undefined;
do {
const stackResourcesResponse = await this.sdk.cloudFormation().listStackResources({
StackName: this.stackName,
NextToken: nextToken,
}).promise();
ret.push(...(stackResourcesResponse.StackResourceSummaries ?? []));
nextToken = stackResourcesResponse.NextToken;
} while (nextToken);
return ret;
}
}