/
cloudformation-deployments.ts
541 lines (472 loc) · 19 KB
/
cloudformation-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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
import * as path from 'path';
import * as cxapi from '@aws-cdk/cx-api';
import { AssetManifest } from 'cdk-assets';
import * as fs from 'fs-extra';
import { Tag } from '../cdk-toolkit';
import { debug, warning } from '../logging';
import { publishAssets } from '../util/asset-publishing';
import { Mode } from './aws-auth/credentials';
import { ISDK } from './aws-auth/sdk';
import { SdkProvider } from './aws-auth/sdk-provider';
import { deployStack, DeployStackResult, destroyStack } from './deploy-stack';
import { LazyListStackResources, ListStackResources } from './evaluate-cloudformation-template';
import { ToolkitInfo } from './toolkit-info';
import { CloudFormationStack, Template } from './util/cloudformation';
import { StackActivityProgress } from './util/cloudformation/stack-activity-monitor';
import { replaceEnvPlaceholders } from './util/placeholders';
/**
* SDK obtained by assuming the lookup role
* for a given environment
*/
export interface PreparedSdkWithLookupRoleForEnvironment {
/**
* The SDK for the given environment
*/
readonly sdk: ISDK;
/**
* The resolved environment for the stack
* (no more 'unknown-account/unknown-region')
*/
readonly resolvedEnvironment: cxapi.Environment;
/**
* Whether or not the assume role was successful.
* If the assume role was not successful (false)
* then that means that the 'sdk' returned contains
* the default credentials (not the assume role credentials)
*/
readonly didAssumeRole: boolean;
}
/**
* Try to use the bootstrap lookupRole. There are two scenarios that are handled here
* 1. The lookup role may not exist (it was added in bootstrap stack version 7)
* 2. The lookup role may not have the correct permissions (ReadOnlyAccess was added in
* bootstrap stack version 8)
*
* In the case of 1 (lookup role doesn't exist) `forEnvironment` will either:
* 1. Return the default credentials if the default credentials are for the stack account
* 2. Throw an error if the default credentials are not for the stack account.
*
* If we successfully assume the lookup role we then proceed to 2 and check whether the bootstrap
* stack version is valid. If it is not we throw an error which should be handled in the calling
* function (and fallback to use a different role, etc)
*
* If we do not successfully assume the lookup role, but do get back the default credentials
* then return those and note that we are returning the default credentials. The calling
* function can then decide to use them or fallback to another role.
*/
export async function prepareSdkWithLookupRoleFor(
sdkProvider: SdkProvider,
stack: cxapi.CloudFormationStackArtifact,
): Promise<PreparedSdkWithLookupRoleForEnvironment> {
const resolvedEnvironment = await sdkProvider.resolveEnvironment(stack.environment);
// Substitute any placeholders with information about the current environment
const arns = await replaceEnvPlaceholders({
lookupRoleArn: stack.lookupRole?.arn,
}, resolvedEnvironment, sdkProvider);
// try to assume the lookup role
const warningMessage = `Could not assume ${arns.lookupRoleArn}, proceeding anyway.`;
const upgradeMessage = `(To get rid of this warning, please upgrade to bootstrap version >= ${stack.lookupRole?.requiresBootstrapStackVersion})`;
try {
const stackSdk = await sdkProvider.forEnvironment(resolvedEnvironment, Mode.ForReading, {
assumeRoleArn: arns.lookupRoleArn,
assumeRoleExternalId: stack.lookupRole?.assumeRoleExternalId,
});
// if we succeed in assuming the lookup role, make sure we have the correct bootstrap stack version
if (stackSdk.didAssumeRole && stack.lookupRole?.bootstrapStackVersionSsmParameter && stack.lookupRole.requiresBootstrapStackVersion) {
const version = await ToolkitInfo.versionFromSsmParameter(stackSdk.sdk, stack.lookupRole.bootstrapStackVersionSsmParameter);
if (version < stack.lookupRole.requiresBootstrapStackVersion) {
throw new Error(`Bootstrap stack version '${stack.lookupRole.requiresBootstrapStackVersion}' is required, found version '${version}'.`);
}
// we may not have assumed the lookup role because one was not provided
// if that is the case then don't print the upgrade warning
} else if (!stackSdk.didAssumeRole && stack.lookupRole?.requiresBootstrapStackVersion) {
warning(upgradeMessage);
}
return { ...stackSdk, resolvedEnvironment };
} catch (e) {
debug(e);
// only print out the warnings if the lookupRole exists AND there is a required
// bootstrap version, otherwise the warnings will print `undefined`
if (stack.lookupRole && stack.lookupRole.requiresBootstrapStackVersion) {
warning(warningMessage);
warning(upgradeMessage);
}
throw (e);
}
}
export interface DeployStackOptions {
/**
* Stack to deploy
*/
stack: cxapi.CloudFormationStackArtifact;
/**
* Execution role for the deployment (pass through to CloudFormation)
*
* @default - Current role
*/
roleArn?: string;
/**
* Topic ARNs to send a message when deployment finishes (pass through to CloudFormation)
*
* @default - No notifications
*/
notificationArns?: string[];
/**
* Override name under which stack will be deployed
*
* @default - Use artifact default
*/
deployName?: string;
/**
* Don't show stack deployment events, just wait
*
* @default false
*/
quiet?: boolean;
/**
* Name of the toolkit stack, if not the default name
*
* @default 'CDKToolkit'
*/
toolkitStackName?: string;
/**
* List of asset IDs which should NOT be built or uploaded
*
* @default - Build all assets
*/
reuseAssets?: string[];
/**
* Stack tags (pass through to CloudFormation)
*/
tags?: Tag[];
/**
* Stage the change set but don't execute it
*
* @default - false
*/
execute?: boolean;
/**
* Optional name to use for the CloudFormation change set.
* If not provided, a name will be generated automatically.
*/
changeSetName?: string;
/**
* Force deployment, even if the deployed template is identical to the one we are about to deploy.
* @default false deployment will be skipped if the template is identical
*/
force?: boolean;
/**
* Extra parameters for CloudFormation
* @default - no additional parameters will be passed to the template
*/
parameters?: { [name: string]: string | undefined };
/**
* Use previous values for unspecified parameters
*
* If not set, all parameters must be specified for every deployment.
*
* @default true
*/
usePreviousParameters?: boolean;
/**
* Display mode for stack deployment progress.
*
* @default - StackActivityProgress.Bar - stack events will be displayed for
* the resource currently being deployed.
*/
progress?: StackActivityProgress;
/**
* Whether we are on a CI system
*
* @default false
*/
readonly ci?: boolean;
/**
* Rollback failed deployments
*
* @default true
*/
readonly rollback?: boolean;
/*
* Whether to perform a 'hotswap' deployment.
* A 'hotswap' deployment will attempt to short-circuit CloudFormation
* and update the affected resources like Lambda functions directly.
*
* @default - false for regular deployments, true for 'watch' deployments
*/
readonly hotswap?: boolean;
/**
* The extra string to append to the User-Agent header when performing AWS SDK calls.
*
* @default - nothing extra is appended to the User-Agent header
*/
readonly extraUserAgent?: string;
}
export interface DestroyStackOptions {
stack: cxapi.CloudFormationStackArtifact;
deployName?: string;
roleArn?: string;
quiet?: boolean;
force?: boolean;
}
export interface StackExistsOptions {
stack: cxapi.CloudFormationStackArtifact;
deployName?: string;
}
export interface ProvisionerProps {
sdkProvider: SdkProvider;
}
/**
* SDK obtained by assuming the deploy role
* for a given environment
*/
export interface PreparedSdkForEnvironment {
/**
* The SDK for the given environment
*/
readonly stackSdk: ISDK;
/**
* The resolved environment for the stack
* (no more 'unknown-account/unknown-region')
*/
readonly resolvedEnvironment: cxapi.Environment;
/**
* The Execution Role that should be passed to CloudFormation.
*
* @default - no execution role is used
*/
readonly cloudFormationRoleArn?: string;
}
/**
* Helper class for CloudFormation deployments
*
* Looks us the right SDK and Bootstrap stack to deploy a given
* stack artifact.
*/
export class CloudFormationDeployments {
private readonly sdkProvider: SdkProvider;
constructor(props: ProvisionerProps) {
this.sdkProvider = props.sdkProvider;
}
public async readCurrentTemplateWithNestedStacks(rootStackArtifact: cxapi.CloudFormationStackArtifact): Promise<Template> {
const sdk = await this.prepareSdkWithLookupOrDeployRole(rootStackArtifact);
const deployedTemplate = await this.readCurrentTemplate(rootStackArtifact, sdk);
await this.addNestedTemplatesToGeneratedAndDeployedStacks(rootStackArtifact, sdk, {
generatedTemplate: rootStackArtifact.template,
deployedTemplate: deployedTemplate,
deployedStackName: rootStackArtifact.stackName,
});
return deployedTemplate;
}
public async readCurrentTemplate(stackArtifact: cxapi.CloudFormationStackArtifact, sdk?: ISDK): Promise<Template> {
debug(`Reading existing template for stack ${stackArtifact.displayName}.`);
if (!sdk) {
sdk = await this.prepareSdkWithLookupOrDeployRole(stackArtifact);
}
return this.readCurrentStackTemplate(stackArtifact.stackName, sdk);
}
public async deployStack(options: DeployStackOptions): Promise<DeployStackResult> {
const { stackSdk, resolvedEnvironment, cloudFormationRoleArn } = await this.prepareSdkFor(options.stack, options.roleArn);
const toolkitInfo = await ToolkitInfo.lookup(resolvedEnvironment, stackSdk, options.toolkitStackName);
// Publish any assets before doing the actual deploy
await this.publishStackAssets(options.stack, toolkitInfo);
// Do a verification of the bootstrap stack version
await this.validateBootstrapStackVersion(
options.stack.stackName,
options.stack.requiresBootstrapStackVersion,
options.stack.bootstrapStackVersionSsmParameter,
toolkitInfo);
return deployStack({
stack: options.stack,
resolvedEnvironment,
deployName: options.deployName,
notificationArns: options.notificationArns,
quiet: options.quiet,
sdk: stackSdk,
sdkProvider: this.sdkProvider,
roleArn: cloudFormationRoleArn,
reuseAssets: options.reuseAssets,
toolkitInfo,
tags: options.tags,
execute: options.execute,
changeSetName: options.changeSetName,
force: options.force,
parameters: options.parameters,
usePreviousParameters: options.usePreviousParameters,
progress: options.progress,
ci: options.ci,
rollback: options.rollback,
hotswap: options.hotswap,
extraUserAgent: options.extraUserAgent,
});
}
public async destroyStack(options: DestroyStackOptions): Promise<void> {
const { stackSdk, cloudFormationRoleArn: roleArn } = await this.prepareSdkFor(options.stack, options.roleArn);
return destroyStack({
sdk: stackSdk,
roleArn,
stack: options.stack,
deployName: options.deployName,
quiet: options.quiet,
});
}
public async stackExists(options: StackExistsOptions): Promise<boolean> {
const { stackSdk } = await this.prepareSdkFor(options.stack, undefined, Mode.ForReading);
const stack = await CloudFormationStack.lookup(stackSdk.cloudFormation(), options.deployName ?? options.stack.stackName);
return stack.exists;
}
private async prepareSdkWithLookupOrDeployRole(stackArtifact: cxapi.CloudFormationStackArtifact): Promise<ISDK> {
// try to assume the lookup role
try {
const result = await prepareSdkWithLookupRoleFor(this.sdkProvider, stackArtifact);
if (result.didAssumeRole) {
return result.sdk;
}
} catch { }
// fall back to the deploy role
return (await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading)).stackSdk;
}
private async readCurrentStackTemplate(stackName: string, stackSdk: ISDK) : Promise<Template> {
const cfn = stackSdk.cloudFormation();
const stack = await CloudFormationStack.lookup(cfn, stackName);
return stack.template();
}
private async addNestedTemplatesToGeneratedAndDeployedStacks(
rootStackArtifact: cxapi.CloudFormationStackArtifact,
sdk: ISDK,
parentTemplates: StackTemplates,
): Promise<void> {
const listStackResources = parentTemplates.deployedStackName ? new LazyListStackResources(sdk, parentTemplates.deployedStackName) : undefined;
for (const [nestedStackLogicalId, generatedNestedStackResource] of Object.entries(parentTemplates.generatedTemplate.Resources ?? {})) {
if (!this.isCdkManagedNestedStack(generatedNestedStackResource)) {
continue;
}
const assetPath = generatedNestedStackResource.Metadata['aws:asset:path'];
const nestedStackTemplates = await this.getNestedStackTemplates(rootStackArtifact, assetPath, nestedStackLogicalId, listStackResources, sdk);
generatedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.generatedTemplate;
const deployedParentTemplate = parentTemplates.deployedTemplate;
deployedParentTemplate.Resources = deployedParentTemplate.Resources ?? {};
const deployedNestedStackResource = deployedParentTemplate.Resources[nestedStackLogicalId] ?? {};
deployedParentTemplate.Resources[nestedStackLogicalId] = deployedNestedStackResource;
deployedNestedStackResource.Type = deployedNestedStackResource.Type ?? 'AWS::CloudFormation::Stack';
deployedNestedStackResource.Properties = deployedNestedStackResource.Properties ?? {};
deployedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.deployedTemplate;
await this.addNestedTemplatesToGeneratedAndDeployedStacks(
rootStackArtifact,
sdk,
nestedStackTemplates,
);
}
}
private async getNestedStackTemplates(
rootStackArtifact: cxapi.CloudFormationStackArtifact, nestedTemplateAssetPath: string, nestedStackLogicalId: string,
listStackResources: ListStackResources | undefined, sdk: ISDK,
): Promise<StackTemplates> {
const nestedTemplatePath = path.join(rootStackArtifact.assembly.directory, nestedTemplateAssetPath);
// CFN generates the nested stack name in the form `ParentStackName-NestedStackLogicalID-SomeHashWeCan'tCompute,
// the arn is of the form: arn:aws:cloudformation:region:123456789012:stack/NestedStackName/AnotherHashWeDon'tNeed
// so we get the ARN and manually extract the name.
const nestedStackArn = await this.getNestedStackArn(nestedStackLogicalId, listStackResources);
const deployedStackName = nestedStackArn?.slice(nestedStackArn.indexOf('/') + 1, nestedStackArn.lastIndexOf('/'));
return {
generatedTemplate: JSON.parse(fs.readFileSync(nestedTemplatePath, 'utf-8')),
deployedTemplate: deployedStackName
? await this.readCurrentStackTemplate(deployedStackName, sdk)
: {},
deployedStackName,
};
}
private async getNestedStackArn(
nestedStackLogicalId: string, listStackResources?: ListStackResources,
): Promise<string | undefined> {
try {
const stackResources = await listStackResources?.listStackResources();
return stackResources?.find(sr => sr.LogicalResourceId === nestedStackLogicalId)?.PhysicalResourceId;
} catch (e) {
if (e.message.startsWith('Stack with id ') && e.message.endsWith(' does not exist')) {
return;
}
throw e;
}
}
private isCdkManagedNestedStack(stackResource: any): stackResource is NestedStackResource {
return stackResource.Type === 'AWS::CloudFormation::Stack' && stackResource.Metadata && stackResource.Metadata['aws:asset:path'];
}
/**
* Get the environment necessary for touching the given stack
*
* Returns the following:
*
* - The resolved environment for the stack (no more 'unknown-account/unknown-region')
* - SDK loaded with the right credentials for calling `CreateChangeSet`.
* - The Execution Role that should be passed to CloudFormation.
*/
private async prepareSdkFor(
stack: cxapi.CloudFormationStackArtifact,
roleArn?: string,
mode = Mode.ForWriting,
): Promise<PreparedSdkForEnvironment> {
if (!stack.environment) {
throw new Error(`The stack ${stack.displayName} does not have an environment`);
}
const resolvedEnvironment = await this.sdkProvider.resolveEnvironment(stack.environment);
// Substitute any placeholders with information about the current environment
const arns = await replaceEnvPlaceholders({
assumeRoleArn: stack.assumeRoleArn,
// Use the override if given, otherwise use the field from the stack
cloudFormationRoleArn: roleArn ?? stack.cloudFormationExecutionRoleArn,
}, resolvedEnvironment, this.sdkProvider);
const stackSdk = await this.sdkProvider.forEnvironment(resolvedEnvironment, mode, {
assumeRoleArn: arns.assumeRoleArn,
assumeRoleExternalId: stack.assumeRoleExternalId,
});
return {
stackSdk: stackSdk.sdk,
resolvedEnvironment,
cloudFormationRoleArn: arns.cloudFormationRoleArn,
};
}
/**
* Publish all asset manifests that are referenced by the given stack
*/
private async publishStackAssets(stack: cxapi.CloudFormationStackArtifact, toolkitInfo: ToolkitInfo) {
const stackEnv = await this.sdkProvider.resolveEnvironment(stack.environment);
const assetArtifacts = stack.dependencies.filter(isAssetManifestArtifact);
for (const assetArtifact of assetArtifacts) {
await this.validateBootstrapStackVersion(
stack.stackName,
assetArtifact.requiresBootstrapStackVersion,
assetArtifact.bootstrapStackVersionSsmParameter,
toolkitInfo);
const manifest = AssetManifest.fromFile(assetArtifact.file);
await publishAssets(manifest, this.sdkProvider, stackEnv);
}
}
/**
* Validate that the bootstrap stack has the right version for this stack
*/
private async validateBootstrapStackVersion(
stackName: string,
requiresBootstrapStackVersion: number | undefined,
bootstrapStackVersionSsmParameter: string | undefined,
toolkitInfo: ToolkitInfo) {
if (requiresBootstrapStackVersion === undefined) { return; }
try {
await toolkitInfo.validateVersion(requiresBootstrapStackVersion, bootstrapStackVersionSsmParameter);
} catch (e) {
throw new Error(`${stackName}: ${e.message}`);
}
}
}
function isAssetManifestArtifact(art: cxapi.CloudArtifact): art is cxapi.AssetManifestArtifact {
return art instanceof cxapi.AssetManifestArtifact;
}
interface StackTemplates {
readonly generatedTemplate: any;
readonly deployedTemplate: any;
readonly deployedStackName: string | undefined;
}
interface NestedStackResource {
readonly Metadata: { 'aws:asset:path': string };
readonly Properties: any;
}