Skip to content

Commit 1767b61

Browse files
authored
feat(toolkit): add '--reuse-asset' option (#1918)
Reusing assets avoids rebuilding an asset and just reuses the currently deployed one. Especially helpful for Docker containers that take a long time to build. Fixes #1916
1 parent aa08b95 commit 1767b61

File tree

10 files changed

+327
-111
lines changed

10 files changed

+327
-111
lines changed

packages/aws-cdk/bin/cdk.ts

+14-73
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import fs = require('fs-extra');
66
import util = require('util');
77
import yargs = require('yargs');
88

9-
import { bootstrapEnvironment, deployStack, destroyStack, loadToolkitInfo, SDK } from '../lib';
9+
import { bootstrapEnvironment, destroyStack, SDK } from '../lib';
1010
import { environmentsFromDescriptors, globEnvironmentsFromStacks } from '../lib/api/cxapp/environments';
1111
import { execProgram } from '../lib/api/cxapp/exec';
1212
import { AppStacks, ExtendedStackSelection, listStackNames } from '../lib/api/cxapp/stacks';
13-
import { CloudFormationDeploymentTarget } from '../lib/api/deployment-target';
13+
import { CloudFormationDeploymentTarget, DEFAULT_TOOLKIT_STACK_NAME } from '../lib/api/deployment-target';
1414
import { leftPad } from '../lib/api/util/string-manipulation';
1515
import { CdkToolkit } from '../lib/cdk-toolkit';
16-
import { printSecurityDiff, RequireApproval } from '../lib/diff';
16+
import { RequireApproval } from '../lib/diff';
1717
import { availableInitLanguages, cliInit, printAvailableTemplates } from '../lib/init';
1818
import { interactive } from '../lib/interactive';
1919
import { data, debug, error, highlight, print, setVerbose, success } from '../lib/logging';
@@ -27,8 +27,6 @@ import { VERSION } from '../lib/version';
2727
const promptly = require('promptly');
2828
const confirm = util.promisify(promptly.confirm);
2929

30-
const DEFAULT_TOOLKIT_STACK_NAME = 'CDKToolkit';
31-
3230
// tslint:disable:no-shadowed-variable max-line-length
3331
async function parseCommandLineArguments() {
3432
const initTemplateLanuages = await availableInitLanguages;
@@ -61,6 +59,7 @@ async function parseCommandLineArguments() {
6159
.option('numbered', { type: 'boolean', alias: 'n', desc: 'prefix filenames with numbers to indicate deployment ordering' }))
6260
.command('bootstrap [ENVIRONMENTS..]', 'Deploys the CDK toolkit stack into an AWS environment')
6361
.command('deploy [STACKS..]', 'Deploys the stack(s) named STACKS into your AWS account', yargs => yargs
62+
.option('build-exclude', { type: 'array', alias: 'E', nargs: 1, desc: 'do not rebuild asset with the given ID. Can be specified multiple times.', default: [] })
6463
.option('exclusively', { type: 'boolean', alias: 'e', desc: 'only deploy requested stacks, don\'t include dependencies' })
6564
.option('require-approval', { type: 'string', choices: [RequireApproval.Never, RequireApproval.AnyChange, RequireApproval.Broadening], desc: 'what security-sensitive changes need manual approval' }))
6665
.option('ci', { type: 'boolean', desc: 'Force CI detection. Use --no-ci to disable CI autodetection.', default: process.env.CI !== undefined })
@@ -87,6 +86,7 @@ async function parseCommandLineArguments() {
8786
].join('\n\n'))
8887
.argv;
8988
}
89+
9090
if (!process.stdout.isTTY) {
9191
colors.disable();
9292
}
@@ -191,7 +191,15 @@ async function initCommandLine() {
191191
return await cliBootstrap(args.ENVIRONMENTS, toolkitStackName, args.roleArn);
192192

193193
case 'deploy':
194-
return await cliDeploy(args.STACKS, args.exclusively, toolkitStackName, args.roleArn, configuration.settings.get(['requireApproval']), args.ci);
194+
return await cli.deploy({
195+
stackNames: args.STACKS,
196+
exclusively: args.exclusively,
197+
toolkitStackName,
198+
roleArn: args.roleArn,
199+
requireApproval: configuration.settings.get(['requireApproval']),
200+
ci: args.ci,
201+
reuseAssets: args['build-exclude']
202+
});
195203

196204
case 'destroy':
197205
return await cliDestroy(args.STACKS, args.exclusively, args.force, args.roleArn);
@@ -340,73 +348,6 @@ async function initCommandLine() {
340348
return 0; // exit-code
341349
}
342350
343-
async function cliDeploy(stackNames: string[],
344-
exclusively: boolean,
345-
toolkitStackName: string,
346-
roleArn: string | undefined,
347-
requireApproval: RequireApproval,
348-
ci: boolean) {
349-
if (requireApproval === undefined) { requireApproval = RequireApproval.Broadening; }
350-
351-
const stacks = await appStacks.selectStacks(stackNames, exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream);
352-
353-
for (const stack of stacks) {
354-
if (stacks.length !== 1) { highlight(stack.name); }
355-
if (!stack.environment) {
356-
// tslint:disable-next-line:max-line-length
357-
throw new Error(`Stack ${stack.name} does not define an environment, and AWS credentials could not be obtained from standard locations or no region was configured.`);
358-
}
359-
const toolkitInfo = await loadToolkitInfo(stack.environment, aws, toolkitStackName);
360-
361-
if (requireApproval !== RequireApproval.Never) {
362-
const currentTemplate = await provisioner.readCurrentTemplate(stack);
363-
if (printSecurityDiff(currentTemplate, stack, requireApproval)) {
364-
365-
// only talk to user if we STDIN is a terminal (otherwise, fail)
366-
if (!process.stdin.isTTY) {
367-
throw new Error(
368-
'"--require-approval" is enabled and stack includes security-sensitive updates, ' +
369-
'but terminal (TTY) is not attached so we are unable to get a confirmation from the user');
370-
}
371-
372-
const confirmed = await confirm(`Do you wish to deploy these changes (y/n)?`);
373-
if (!confirmed) { throw new Error('Aborted by user'); }
374-
}
375-
}
376-
377-
if (stack.name !== stack.originalName) {
378-
print('%s: deploying... (was %s)', colors.bold(stack.name), colors.bold(stack.originalName));
379-
} else {
380-
print('%s: deploying...', colors.bold(stack.name));
381-
}
382-
383-
try {
384-
const result = await deployStack({ stack, sdk: aws, toolkitInfo, deployName: stack.name, roleArn, ci });
385-
const message = result.noOp
386-
? ` %s (no changes)`
387-
: ` %s`;
388-
389-
success('\n' + message, stack.name);
390-
391-
if (Object.keys(result.outputs).length > 0) {
392-
print('\nOutputs:');
393-
}
394-
395-
for (const name of Object.keys(result.outputs)) {
396-
const value = result.outputs[name];
397-
print('%s.%s = %s', colors.cyan(stack.name), colors.cyan(name), colors.underline(colors.cyan(value)));
398-
}
399-
400-
print('\nStack ARN:');
401-
402-
data(result.stackArn);
403-
} catch (e) {
404-
error('\n ❌ %s failed: %s', colors.bold(stack.name), e);
405-
throw e;
406-
}
407-
}
408-
}
409-
410351
async function cliDestroy(stackNames: string[], exclusively: boolean, force: boolean, roleArn: string | undefined) {
411352
const stacks = await appStacks.selectStacks(stackNames, exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Downstream);
412353

packages/aws-cdk/lib/api/deploy-stack.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface DeployStackOptions {
3131
deployName?: string;
3232
quiet?: boolean;
3333
ci?: boolean;
34+
reuseAssets?: string[];
3435
}
3536

3637
const LARGE_TEMPLATE_SIZE_KB = 50;
@@ -40,7 +41,7 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
4041
throw new Error(`The stack ${options.stack.name} does not have an environment`);
4142
}
4243

43-
const params = await prepareAssets(options.stack, options.toolkitInfo, options.ci);
44+
const params = await prepareAssets(options.stack, options.toolkitInfo, options.ci, options.reuseAssets);
4445

4546
const deployName = options.deployName || options.stack.name;
4647

packages/aws-cdk/lib/api/deployment-target.ts

+34-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ import cxapi = require('@aws-cdk/cx-api');
22
import { debug } from '../logging';
33
import { deserializeStructure } from '../serialize';
44
import { Mode } from './aws-auth/credentials';
5+
import { deployStack, DeployStackResult } from './deploy-stack';
6+
import { loadToolkitInfo } from './toolkit-info';
57
import { SDK } from './util/sdk';
68

9+
export const DEFAULT_TOOLKIT_STACK_NAME = 'CDKToolkit';
10+
711
export type Template = { [key: string]: any };
812

913
/**
@@ -13,6 +17,17 @@ export type Template = { [key: string]: any };
1317
*/
1418
export interface IDeploymentTarget {
1519
readCurrentTemplate(stack: cxapi.SynthesizedStack): Promise<Template>;
20+
deployStack(options: DeployStackOptions): Promise<DeployStackResult>;
21+
}
22+
23+
export interface DeployStackOptions {
24+
stack: cxapi.SynthesizedStack;
25+
roleArn?: string;
26+
deployName?: string;
27+
quiet?: boolean;
28+
ci?: boolean;
29+
toolkitStackName?: string;
30+
reuseAssets?: string[];
1631
}
1732

1833
export interface ProvisionerProps {
@@ -23,13 +38,16 @@ export interface ProvisionerProps {
2338
* Default provisioner (applies to CloudFormation).
2439
*/
2540
export class CloudFormationDeploymentTarget implements IDeploymentTarget {
26-
constructor(private readonly props: ProvisionerProps) {
41+
private readonly aws: SDK;
42+
43+
constructor(props: ProvisionerProps) {
44+
this.aws = props.aws;
2745
}
2846

2947
public async readCurrentTemplate(stack: cxapi.SynthesizedStack): Promise<Template> {
3048
debug(`Reading existing template for stack ${stack.name}.`);
3149

32-
const cfn = await this.props.aws.cloudFormation(stack.environment, Mode.ForReading);
50+
const cfn = await this.aws.cloudFormation(stack.environment, Mode.ForReading);
3351
try {
3452
const response = await cfn.getTemplate({ StackName: stack.name }).promise();
3553
return (response.TemplateBody && deserializeStructure(response.TemplateBody)) || {};
@@ -41,4 +59,18 @@ export class CloudFormationDeploymentTarget implements IDeploymentTarget {
4159
}
4260
}
4361
}
62+
63+
public async deployStack(options: DeployStackOptions): Promise<DeployStackResult> {
64+
const toolkitInfo = await loadToolkitInfo(options.stack.environment, this.aws, options.toolkitStackName || DEFAULT_TOOLKIT_STACK_NAME);
65+
return deployStack({
66+
stack: options.stack,
67+
deployName: options.deployName,
68+
roleArn: options.roleArn,
69+
quiet: options.quiet,
70+
sdk: this.aws,
71+
ci: options.ci,
72+
reuseAssets: options.reuseAssets,
73+
toolkitInfo,
74+
});
75+
}
4476
}

packages/aws-cdk/lib/assets.ts

+33-11
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ import { zipDirectory } from './archive';
1010
import { prepareContainerAsset } from './docker';
1111
import { debug, success } from './logging';
1212

13-
export async function prepareAssets(stack: SynthesizedStack, toolkitInfo?: ToolkitInfo, ci?: boolean): Promise<CloudFormation.Parameter[]> {
13+
// tslint:disable-next-line:max-line-length
14+
export async function prepareAssets(stack: SynthesizedStack, toolkitInfo?: ToolkitInfo, ci?: boolean, reuse?: string[]): Promise<CloudFormation.Parameter[]> {
15+
reuse = reuse || [];
1416
const assets = findAssets(stack.metadata);
17+
1518
if (assets.length === 0) {
1619
return [];
1720
}
@@ -21,40 +24,51 @@ export async function prepareAssets(stack: SynthesizedStack, toolkitInfo?: Toolk
2124
throw new Error(`This stack uses assets, so the toolkit stack must be deployed to the environment (Run "${colors.blue("cdk bootstrap " + stack.environment!.name)}")`);
2225
}
2326

24-
debug('Preparing assets');
2527
let params = new Array<CloudFormation.Parameter>();
2628
for (const asset of assets) {
27-
debug(` - ${asset.path} (${asset.packaging})`);
29+
// FIXME: Should have excluded by construct path here instead of by unique ID, preferably using
30+
// minimatch so we can support globs. Maybe take up during artifact refactoring.
31+
const reuseAsset = reuse.indexOf(asset.id) > -1;
32+
33+
if (reuseAsset) {
34+
debug(`Preparing asset ${asset.id}: ${JSON.stringify(asset)} (reusing)`);
35+
} else {
36+
debug(`Preparing asset ${asset.id}: ${JSON.stringify(asset)}`);
37+
}
2838

29-
params = params.concat(await prepareAsset(asset, toolkitInfo, ci));
39+
params = params.concat(await prepareAsset(asset, toolkitInfo, reuseAsset, ci));
3040
}
3141

3242
return params;
3343
}
3444

35-
async function prepareAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitInfo, ci?: boolean): Promise<CloudFormation.Parameter[]> {
36-
debug('Preparing asset', JSON.stringify(asset));
45+
// tslint:disable-next-line:max-line-length
46+
async function prepareAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitInfo, reuse: boolean, ci?: boolean): Promise<CloudFormation.Parameter[]> {
3747
switch (asset.packaging) {
3848
case 'zip':
39-
return await prepareZipAsset(asset, toolkitInfo);
49+
return await prepareZipAsset(asset, toolkitInfo, reuse);
4050
case 'file':
41-
return await prepareFileAsset(asset, toolkitInfo);
51+
return await prepareFileAsset(asset, toolkitInfo, reuse);
4252
case 'container-image':
43-
return await prepareContainerAsset(asset, toolkitInfo, ci);
53+
return await prepareContainerAsset(asset, toolkitInfo, reuse, ci);
4454
default:
4555
// tslint:disable-next-line:max-line-length
4656
throw new Error(`Unsupported packaging type: ${(asset as any).packaging}. You might need to upgrade your aws-cdk toolkit to support this asset type.`);
4757
}
4858
}
4959

50-
async function prepareZipAsset(asset: FileAssetMetadataEntry, toolkitInfo: ToolkitInfo): Promise<CloudFormation.Parameter[]> {
60+
async function prepareZipAsset(asset: FileAssetMetadataEntry, toolkitInfo: ToolkitInfo, reuse: boolean): Promise<CloudFormation.Parameter[]> {
61+
if (reuse) {
62+
return await prepareFileAsset(asset, toolkitInfo, reuse);
63+
}
64+
5165
debug('Preparing zip asset from directory:', asset.path);
5266
const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-assets'));
5367
try {
5468
const archiveFile = path.join(staging, 'archive.zip');
5569
await zipDirectory(asset.path, archiveFile);
5670
debug('zip archive:', archiveFile);
57-
return await prepareFileAsset(asset, toolkitInfo, archiveFile, 'application/zip');
71+
return await prepareFileAsset(asset, toolkitInfo, reuse, archiveFile, 'application/zip');
5872
} finally {
5973
await fs.remove(staging);
6074
}
@@ -69,9 +83,17 @@ async function prepareZipAsset(asset: FileAssetMetadataEntry, toolkitInfo: Toolk
6983
async function prepareFileAsset(
7084
asset: FileAssetMetadataEntry,
7185
toolkitInfo: ToolkitInfo,
86+
reuse: boolean,
7287
filePath?: string,
7388
contentType?: string): Promise<CloudFormation.Parameter[]> {
7489

90+
if (reuse) {
91+
return [
92+
{ ParameterKey: asset.s3BucketParameter, UsePreviousValue: true },
93+
{ ParameterKey: asset.s3KeyParameter, UsePreviousValue: true },
94+
];
95+
}
96+
7597
filePath = filePath || asset.path;
7698
debug('Preparing file asset:', filePath);
7799

0 commit comments

Comments
 (0)