From 76de449de0438cf81106db538255b881ec4c1f03 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Fri, 21 Aug 2020 11:47:19 +0200 Subject: [PATCH 1/4] feat(bootstrap): customizable bootstrap template There are many requests for customization of the built-in bootstrapping template. Rather than implementing each and every request, it's more productive to allow users to help themselves. This change introduces two new flags to `cdk bootstrap`: * `cdk bootstrap --show-template`: prints the current template to stdout, which people can pipe to a file. * `cdk bootstrap --template FILE`: reads the template from a file instead of using the built-in template. This can be used to arbitrarily customize the bootstrapping template for use in any organization. I know that the documentation changes in this PR are pretty light, but really a Developer Guide topic should be written on bootstrapping, which is next on my TODO list. --- packages/aws-cdk/CONTRIBUTING.md | 10 +- packages/aws-cdk/README.md | 18 +- packages/aws-cdk/bin/cdk.ts | 46 +++-- .../api/bootstrap/bootstrap-environment.ts | 161 ++++++++++++------ .../lib/api/bootstrap/bootstrap-props.ts | 37 ++-- .../lib/api/bootstrap/deploy-bootstrap.ts | 10 +- packages/aws-cdk/lib/cdk-toolkit.ts | 14 +- packages/aws-cdk/test/api/bootstrap.test.ts | 34 ++-- packages/aws-cdk/test/api/bootstrap2.test.ts | 27 +-- packages/aws-cdk/test/cdk-toolkit.test.ts | 33 ++-- .../test/integ/cli/bootstrapping.integtest.ts | 32 +++- 11 files changed, 261 insertions(+), 161 deletions(-) diff --git a/packages/aws-cdk/CONTRIBUTING.md b/packages/aws-cdk/CONTRIBUTING.md index 8136fc0604680..0806a8297a242 100644 --- a/packages/aws-cdk/CONTRIBUTING.md +++ b/packages/aws-cdk/CONTRIBUTING.md @@ -30,16 +30,10 @@ $ test/integ/run-against-dist test/integ/cli/test.sh $ test/integ/run-against-release test/integ/cli/test.sh ``` -You can switch out the test script to run the init tests: +To run a single integ test in the source tree: ``` -$ test/integ/run-against-xxx test/integ/init/test-all.sh -``` - -Or even run a single integ test: - -``` -$ test/integ/run-against-xxx test/integ/init/test-cdk-deploy-no-tty.sh +$ test/integ/run-against-repo test/integ/cli/test.sh -t 'SUBSTRING OF THE TEST NAME' ``` ### CLI integration tests diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 7a4695cf8280c..e5d1e185e1b42 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -223,7 +223,7 @@ Example `outputs.json` after deployment of multiple stacks }, "AnotherStack": { "VPCId": "vpc-z0mg270fee16693f" - } + } } ``` @@ -250,9 +250,23 @@ $ # Deploys only to environments foo and bar $ cdk bootstrap --app='node bin/main.js' foo bar ``` -By default, bootstrap stack will be protected from stack termination. This can be disabled using +By default, bootstrap stack will be protected from stack termination. This can be disabled using `--termination-protection` argument. +Your company may require specific tweaks to the bootstrapping template. You can customize the template +to fit your own situation, by exporting the default one to a file and either deploying it yourself +using CloudFormation directly, or by telling the CLI to use a custom template. That looks as follows: + +```console +# Dump the built-in template to a file +$ cdk bootstrap --show-template > bootstrap-template.yaml + +# Edit 'bootstrap-template.yaml' to your liking + +# Tell CDK to use the customized template +$ cdk bootstrap --template bootstrap-template.yaml +``` + #### `cdk doctor` Inspect the current command-line environment and configurations, and collect information that can be useful for troubleshooting problems. It is usually a good idea to include the information provided by this command when submitting diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 016a0855494be..74925c9db71e8 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -5,7 +5,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as colors from 'colors/safe'; import * as yargs from 'yargs'; -import { ToolkitInfo } from '../lib'; +import { ToolkitInfo, BootstrapSource, Bootstrapper } from '../lib'; import { SdkProvider } from '../lib/api/aws-auth'; import { CloudFormationDeployments } from '../lib/api/cloudformation-deployments'; import { CloudExecutable } from '../lib/api/cxapp/cloud-executable'; @@ -76,7 +76,9 @@ async function parseCommandLineArguments() { .option('trust', { type: 'array', desc: 'The AWS account IDs that should be trusted to perform deployments into this environment (may be repeated)', default: [], nargs: 1, requiresArg: true, hidden: true }) .option('cloudformation-execution-policies', { type: 'array', desc: 'The Managed Policy ARNs that should be attached to the role performing deployments into this environment. Required if --trust was passed (may be repeated)', default: [], nargs: 1, requiresArg: true, hidden: true }) .option('force', { alias: 'f', type: 'boolean', desc: 'Always bootstrap even if it would downgrade template version', default: false }) - .option('termination-protection', { type: 'boolean', default: false, desc: 'Toggle CloudFormation termination protection on the bootstrap stacks' }), + .option('termination-protection', { type: 'boolean', default: false, desc: 'Toggle CloudFormation termination protection on the bootstrap stacks' }) + .option('show-template', { type: 'boolean', desc: 'Instead of actual bootstrapping, print the current CLI\'s bootstrapping template to stdout for customization.', default: false }) + .option('template', { type: 'string', requiresArg: true, desc: 'Use the template from the given file instead of the built-in one (use --show-template to obtain an example).' }), ) .command('deploy [STACKS..]', 'Deploys the stack(s) named STACKS into your AWS account', yargs => yargs .option('build-exclude', { type: 'array', alias: 'E', nargs: 1, desc: 'Do not rebuild asset with the given ID. Can be specified multiple times.', default: [] }) @@ -232,30 +234,44 @@ async function initCommandLine() { case 'bootstrap': // Use new bootstrapping if it's requested via environment variable, or if // new style stack synthesis has been configured in `cdk.json`. - let useNewBootstrapping = false; - if (process.env.CDK_NEW_BOOTSTRAP) { + // + // In code it's optimistically called "default" bootstrapping but that is in + // inticipation of flipping the switch, in user messaging we still call it + // "new" bootstrapping. + let source: BootstrapSource = { source: 'legacy' }; + if (args.template) { + print(`Using bootstrapping template from ${args.template}`); + source = { source: 'custom', templateFile: args.template }; + } else if (process.env.CDK_NEW_BOOTSTRAP) { print('CDK_NEW_BOOTSTRAP set, using new-style bootstrapping'); - useNewBootstrapping = true; + source = { source: 'default' }; } else if (configuration.context.get(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT)) { print(`'${cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT}' context set, using new-style bootstrapping`); - useNewBootstrapping = true; + source = { source: 'default' }; } - return await cli.bootstrap(args.ENVIRONMENTS, toolkitStackName, - args.roleArn, - useNewBootstrapping, - argv.force, - { + const bootstrapper = new Bootstrapper(source); + + if (args.showTemplate) { + return await bootstrapper.showTemplate(); + } + + return await cli.bootstrap(args.ENVIRONMENTS, bootstrapper, { + roleArn: args.roleArn, + force: argv.force, + toolkitStackName: toolkitStackName, + execute: args.execute, + tags: configuration.settings.get(['tags']), + terminationProtection: args.terminationProtection, + parameters: { bucketName: configuration.settings.get(['toolkitBucket', 'bucketName']), kmsKeyId: configuration.settings.get(['toolkitBucket', 'kmsKeyId']), qualifier: args.qualifier, publicAccessBlockConfiguration: args.publicAccessBlockConfiguration, - tags: configuration.settings.get(['tags']), - execute: args.execute, trustedAccounts: args.trust, cloudFormationExecutionPolicies: args.cloudformationExecutionPolicies, - terminationProtection: args.terminationProtection, - }); + }, + }); case 'deploy': const parameterMap: { [name: string]: string | undefined } = {}; diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts b/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts index 03205b9e743f9..fcc61f4e7c771 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts @@ -1,70 +1,123 @@ import * as path from 'path'; import * as cxapi from '@aws-cdk/cx-api'; -import { loadStructuredFile } from '../../serialize'; +import { loadStructuredFile, toYAML } from '../../serialize'; import { SdkProvider } from '../aws-auth'; import { DeployStackResult } from '../deploy-stack'; -import { BootstrapEnvironmentOptions } from './bootstrap-props'; -import { deployBootstrapStack } from './deploy-bootstrap'; +import { BootstrapEnvironmentOptions, BootstrappingParameters } from './bootstrap-props'; +import { deployBootstrapStack, bootstrapVersionFromTemplate } from './deploy-bootstrap'; import { legacyBootstrapTemplate } from './legacy-template'; /* eslint-disable max-len */ -/** - * Deploy legacy bootstrap stack - * - * @experimental - */ -export async function bootstrapEnvironment(environment: cxapi.Environment, sdkProvider: SdkProvider, options: BootstrapEnvironmentOptions = {}): Promise { - const params = options.parameters ?? {}; +export type BootstrapSource = + { source: 'legacy' } + | { source: 'default' } + | { source: 'custom'; templateFile: string }; - if (params.trustedAccounts?.length) { - throw new Error('--trust can only be passed for the new bootstrap experience.'); + +export class Bootstrapper { + constructor(private readonly source: BootstrapSource) { + } + + public bootstrapEnvironment(environment: cxapi.Environment, sdkProvider: SdkProvider, options: BootstrapEnvironmentOptions = {}): Promise { + switch (this.source.source) { + case 'legacy': + return this.legacyBootstrap(environment, sdkProvider, options); + case 'default': + return this.defaultBootstrap(environment, sdkProvider, options); + case 'custom': + return this.customBootstrap(environment, sdkProvider, options); + } + } + + public async showTemplate() { + const template = await this.loadTemplate(); + process.stdout.write(`${toYAML(template)}\n`); + } + + /** + * Deploy legacy bootstrap stack + * + * @experimental + */ + private async legacyBootstrap(environment: cxapi.Environment, sdkProvider: SdkProvider, options: BootstrapEnvironmentOptions = {}): Promise { + const params = options.parameters ?? {}; + + if (params.trustedAccounts?.length) { + throw new Error('--trust can only be passed for the new bootstrap experience.'); + } + if (params.cloudFormationExecutionPolicies?.length) { + throw new Error('--cloudformation-execution-policies can only be passed for the new bootstrap experience.'); + } + if (params.qualifier) { + throw new Error('--qualifier can only be passed for the new bootstrap experience.'); + } + + return deployBootstrapStack( + await this.loadTemplate(params), + {}, + environment, + sdkProvider, + options); } - if (params.cloudFormationExecutionPolicies?.length) { - throw new Error('--cloudformation-execution-policies can only be passed for the new bootstrap experience.'); + + /** + * Deploy CI/CD-ready bootstrap stack from template + * + * @experimental + */ + private async defaultBootstrap( + environment: cxapi.Environment, + sdkProvider: SdkProvider, + options: BootstrapEnvironmentOptions = {}): Promise { + + const params = options.parameters ?? {}; + + if (params.trustedAccounts?.length && !params.cloudFormationExecutionPolicies?.length) { + throw new Error('--cloudformation-execution-policies are required if --trust has been passed!'); + } + + const bootstrapTemplate = await this.loadTemplate(); + + return deployBootstrapStack( + bootstrapTemplate, + { + FileAssetsBucketName: params.bucketName, + FileAssetsBucketKmsKeyId: params.kmsKeyId, + TrustedAccounts: params.trustedAccounts?.join(','), + CloudFormationExecutionPolicies: params.cloudFormationExecutionPolicies?.join(','), + Qualifier: params.qualifier, + PublicAccessBlockConfiguration: params.publicAccessBlockConfiguration || params.publicAccessBlockConfiguration === undefined ? 'true' : 'false', + }, + environment, + sdkProvider, + options); } - if (params.qualifier) { - throw new Error('--qualifier can only be passed for the new bootstrap experience.'); + + private async customBootstrap( + environment: cxapi.Environment, + sdkProvider: SdkProvider, + options: BootstrapEnvironmentOptions = {}): Promise { + + // Look at the template, decide whether it's most likely a legacy or modern bootstrap + // template, and use the right bootstrapper for that. + const version = bootstrapVersionFromTemplate(await this.loadTemplate()); + if (version === 0) { + return this.legacyBootstrap(environment, sdkProvider, options); + } else { + return this.defaultBootstrap(environment, sdkProvider, options); + } } - return deployBootstrapStack( - legacyBootstrapTemplate(params), - {}, - environment, - sdkProvider, - options); -} - -/** - * Deploy CI/CD-ready bootstrap stack from template - * - * @experimental - */ -export async function bootstrapEnvironment2( - environment: cxapi.Environment, - sdkProvider: SdkProvider, - options: BootstrapEnvironmentOptions = {}): Promise { - - const params = options.parameters ?? {}; - - if (params.trustedAccounts?.length && !params.cloudFormationExecutionPolicies?.length) { - throw new Error('--cloudformation-execution-policies are required if --trust has been passed!'); + private async loadTemplate(params: BootstrappingParameters = {}): Promise { + switch (this.source.source) { + case 'custom': + return loadStructuredFile(this.source.templateFile); + case 'default': + return loadStructuredFile(path.join(__dirname, 'bootstrap-template.yaml')); + case 'legacy': + return legacyBootstrapTemplate(params); + } } - const bootstrapTemplatePath = path.join(__dirname, 'bootstrap-template.yaml'); - const bootstrapTemplate = await loadStructuredFile(bootstrapTemplatePath); - - return deployBootstrapStack( - bootstrapTemplate, - { - FileAssetsBucketName: params.bucketName, - FileAssetsBucketKmsKeyId: params.kmsKeyId, - TrustedAccounts: params.trustedAccounts?.join(','), - CloudFormationExecutionPolicies: params.cloudFormationExecutionPolicies?.join(','), - Qualifier: params.qualifier, - PublicAccessBlockConfiguration: params.publicAccessBlockConfiguration || params.publicAccessBlockConfiguration === undefined ? 'true' : 'false', - }, - environment, - sdkProvider, - options); } \ No newline at end of file diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts b/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts index ff91b55227c75..2ca605d55e6ac 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts @@ -17,6 +17,26 @@ export interface BootstrapEnvironmentOptions { readonly roleArn?: string; readonly parameters?: BootstrappingParameters; readonly force?: boolean; + + /** + * Whether to execute the changeset or only create it and leave it in review. + * @default true + */ + readonly execute?: boolean; + + /** + * Tags for cdktoolkit stack. + * + * @default - None. + */ + readonly tags?: Tag[]; + + /** + * Whether the stacks created by the bootstrap process should be protected from termination. + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html + * @default true + */ + readonly terminationProtection?: boolean; } /** @@ -36,17 +56,6 @@ export interface BootstrappingParameters { * @default - the default KMS key for S3 will be used. */ readonly kmsKeyId?: string; - /** - * Tags for cdktoolkit stack. - * - * @default - None. - */ - readonly tags?: Tag[]; - /** - * Whether to execute the changeset or only create it and leave it in review. - * @default true - */ - readonly execute?: boolean; /** * The list of AWS account IDs that are trusted to deploy into the environment being bootstrapped. @@ -78,10 +87,4 @@ export interface BootstrappingParameters { */ readonly publicAccessBlockConfiguration?: boolean; - /** - * Whether the stacks created by the bootstrap process should be protected from termination. - * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html - * @default true - */ - readonly terminationProtection?: boolean; } \ No newline at end of file diff --git a/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts b/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts index c30f9cb6138d8..de54472fe34a9 100644 --- a/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts +++ b/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts @@ -16,7 +16,7 @@ export async function deployBootstrapStack( parameters: Record, environment: cxapi.Environment, sdkProvider: SdkProvider, - options: BootstrapEnvironmentOptions): Promise { + options: Omit): Promise { const toolkitStackName = options.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME; @@ -39,7 +39,7 @@ export async function deployBootstrapStack( environment: cxapi.EnvironmentUtils.format(environment.account, environment.region), properties: { templateFile, - terminationProtection: options.parameters?.terminationProtection ?? false, + terminationProtection: options.terminationProtection ?? false, }, }); @@ -52,12 +52,12 @@ export async function deployBootstrapStack( sdkProvider, force: options.force, roleArn: options.roleArn, - tags: options.parameters?.tags, - execute: options?.parameters?.execute, + tags: options.tags, + execute: options.execute, parameters, }); } -function bootstrapVersionFromTemplate(template: any): number { +export function bootstrapVersionFromTemplate(template: any): number { return parseInt(template.Outputs?.[BOOTSTRAP_VERSION_OUTPUT]?.Value ?? '0', 10); } \ No newline at end of file diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 3aec3c4eeef18..c0f14707d71ef 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -6,9 +6,8 @@ import * as colors from 'colors/safe'; import * as fs from 'fs-extra'; import * as promptly from 'promptly'; import { environmentsFromDescriptors, globEnvironmentsFromStacks, looksLikeGlob } from '../lib/api/cxapp/environments'; -import { bootstrapEnvironment } from './api'; import { SdkProvider } from './api/aws-auth'; -import { bootstrapEnvironment2, BootstrappingParameters } from './api/bootstrap'; +import { Bootstrapper, BootstrapEnvironmentOptions } from './api/bootstrap'; import { CloudFormationDeployments } from './api/cloudformation-deployments'; import { CloudAssembly, DefaultSelection, ExtendedStackSelection, StackCollection } from './api/cxapp/cloud-assembly'; import { CloudExecutable } from './api/cxapp/cloud-executable'; @@ -330,9 +329,7 @@ export class CdkToolkit { * all stacks are implicitly selected. * @param toolkitStackName the name to be used for the CDK Toolkit stack. */ - public async bootstrap( - environmentSpecs: string[], toolkitStackName: string | undefined, roleArn: string | undefined, - useNewBootstrapping: boolean, force: boolean | undefined, props: BootstrappingParameters): Promise { + public async bootstrap(environmentSpecs: string[], bootstrapper: Bootstrapper, options: BootstrapEnvironmentOptions): Promise { // If there is an '--app' argument and an environment looks like a glob, we // select the environments from the app. Otherwise use what the user said. @@ -357,12 +354,7 @@ export class CdkToolkit { await Promise.all(environments.map(async (environment) => { success(' ⏳ Bootstrapping environment %s...', colors.blue(environment.name)); try { - const result = await (useNewBootstrapping ? bootstrapEnvironment2 : bootstrapEnvironment)(environment, this.props.sdkProvider, { - toolkitStackName, - roleArn, - force, - parameters: props, - }); + const result = await bootstrapper.bootstrapEnvironment(environment, this.props.sdkProvider, options); const message = result.noOp ? ' ✅ Environment %s bootstrapped (no changes).' : ' ✅ Environment %s bootstrapped.'; diff --git a/packages/aws-cdk/test/api/bootstrap.test.ts b/packages/aws-cdk/test/api/bootstrap.test.ts index 6e3c20daa0933..bda570d3cbe05 100644 --- a/packages/aws-cdk/test/api/bootstrap.test.ts +++ b/packages/aws-cdk/test/api/bootstrap.test.ts @@ -1,5 +1,5 @@ import { CreateChangeSetInput } from 'aws-sdk/clients/cloudformation'; -import { bootstrapEnvironment } from '../../lib'; +import { Bootstrapper } from '../../lib/api/bootstrap'; import { fromYAML } from '../../lib/serialize'; import { MockSdkProvider, SyncHandlerSubsetOf } from '../util/mock-sdk'; @@ -14,10 +14,12 @@ let executed: boolean; let protectedTermination: boolean; let cfnMocks: jest.Mocked>; let changeSetTemplate: any | undefined; +let bootstrapper: Bootstrapper; beforeEach(() => { sdk = new MockSdkProvider(); executed = false; protectedTermination = false; + bootstrapper = new Bootstrapper({ source: 'legacy' }); cfnMocks = { describeStackEvents: jest.fn().mockReturnValue({}), @@ -62,7 +64,7 @@ beforeEach(() => { test('do bootstrap', async () => { // WHEN - const ret = await bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack' }); + const ret = await bootstrapper.bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack' }); // THEN const bucketProperties = changeSetTemplate.Resources.StagingBucket.Properties; @@ -76,7 +78,7 @@ test('do bootstrap', async () => { test('do bootstrap using custom bucket name', async () => { // WHEN - const ret = await bootstrapEnvironment(env, sdk, { + const ret = await bootstrapper.bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack', parameters: { bucketName: 'foobar', @@ -95,7 +97,7 @@ test('do bootstrap using custom bucket name', async () => { test('do bootstrap using KMS CMK', async () => { // WHEN - const ret = await bootstrapEnvironment(env, sdk, { + const ret = await bootstrapper.bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack', parameters: { kmsKeyId: 'myKmsKey', @@ -114,7 +116,7 @@ test('do bootstrap using KMS CMK', async () => { test('bootstrap disable bucket Public Access Block Configuration', async () => { // WHEN - const ret = await bootstrapEnvironment(env, sdk, { + const ret = await bootstrapper.bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack', parameters: { publicAccessBlockConfiguration: false, @@ -133,11 +135,9 @@ test('bootstrap disable bucket Public Access Block Configuration', async () => { test('do bootstrap with custom tags for toolkit stack', async () => { // WHEN - const ret = await bootstrapEnvironment(env, sdk, { + const ret = await bootstrapper.bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack', - parameters: { - tags: [{ Key: 'Foo', Value: 'Bar' }], - }, + tags: [{ Key: 'Foo', Value: 'Bar' }], }); // THEN @@ -151,7 +151,7 @@ test('do bootstrap with custom tags for toolkit stack', async () => { }); test('passing trusted accounts to the old bootstrapping results in an error', async () => { - await expect(bootstrapEnvironment(env, sdk, { + await expect(bootstrapper.bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack', parameters: { trustedAccounts: ['0123456789012'], @@ -162,7 +162,7 @@ test('passing trusted accounts to the old bootstrapping results in an error', as }); test('passing CFN execution policies to the old bootstrapping results in an error', async () => { - await expect(bootstrapEnvironment(env, sdk, { + await expect(bootstrapper.bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack', parameters: { cloudFormationExecutionPolicies: ['arn:aws:iam::aws:policy/AdministratorAccess'], @@ -213,7 +213,7 @@ test('even if the bootstrap stack is in a rollback state, can still retry bootst })); // WHEN - const ret = await bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack' }); + const ret = await bootstrapper.bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack' }); // THEN const bucketProperties = changeSetTemplate.Resources.StagingBucket.Properties; @@ -265,7 +265,7 @@ test('even if the bootstrap stack failed to create, can still retry bootstrappin })); // WHEN - const ret = await bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack' }); + const ret = await bootstrapper.bootstrapEnvironment(env, sdk, { toolkitStackName: 'mockStack' }); // THEN const bucketProperties = changeSetTemplate.Resources.StagingBucket.Properties; @@ -279,7 +279,7 @@ test('even if the bootstrap stack failed to create, can still retry bootstrappin test('stack is not termination protected by default', async () => { // WHEN - await bootstrapEnvironment(env, sdk); + await bootstrapper.bootstrapEnvironment(env, sdk); // THEN expect(executed).toBeTruthy(); @@ -288,10 +288,8 @@ test('stack is not termination protected by default', async () => { test('stack is termination protected when set', async () => { // WHEN - await bootstrapEnvironment(env, sdk, { - parameters: { - terminationProtection: true, - }, + await bootstrapper.bootstrapEnvironment(env, sdk, { + terminationProtection: true, }); // THEN diff --git a/packages/aws-cdk/test/api/bootstrap2.test.ts b/packages/aws-cdk/test/api/bootstrap2.test.ts index 94dbccb2d2a42..d38caa5520498 100644 --- a/packages/aws-cdk/test/api/bootstrap2.test.ts +++ b/packages/aws-cdk/test/api/bootstrap2.test.ts @@ -14,10 +14,15 @@ jest.mock('../../lib/api/toolkit-info', () => ({ }, })); -import { bootstrapEnvironment2 } from '../../lib/api/bootstrap'; +import { Bootstrapper } from '../../lib/api/bootstrap'; import { DeployStackOptions } from '../../lib/api/deploy-stack'; import { MockSdkProvider } from '../util/mock-sdk'; +let bootstrapper: Bootstrapper; +beforeEach(() => { + bootstrapper = new Bootstrapper({ source: 'default' }); +}); + describe('Bootstrapping v2', () => { const env = { account: '123456789012', @@ -32,7 +37,7 @@ describe('Bootstrapping v2', () => { }); test('passes the bucket name as a CFN parameter', async () => { - await bootstrapEnvironment2(env, sdk, { + await bootstrapper.bootstrapEnvironment(env, sdk, { parameters: { bucketName: 'my-bucket-name', }, @@ -47,7 +52,7 @@ describe('Bootstrapping v2', () => { }); test('passes the KMS key ID as a CFN parameter', async () => { - await bootstrapEnvironment2(env, sdk, { + await bootstrapper.bootstrapEnvironment(env, sdk, { parameters: { kmsKeyId: 'my-kms-key-id', }, @@ -62,7 +67,7 @@ describe('Bootstrapping v2', () => { }); test('passes false to PublicAccessBlockConfiguration', async () => { - await bootstrapEnvironment2(env, sdk, { + await bootstrapper.bootstrapEnvironment(env, sdk, { parameters: { publicAccessBlockConfiguration: false, }, @@ -76,7 +81,7 @@ describe('Bootstrapping v2', () => { }); test('passing trusted accounts without CFN managed policies results in an error', async () => { - await expect(bootstrapEnvironment2(env, sdk, { + await expect(bootstrapper.bootstrapEnvironment(env, sdk, { parameters: { trustedAccounts: ['123456789012'], }, @@ -91,7 +96,7 @@ describe('Bootstrapping v2', () => { version: 999, }; - await expect(bootstrapEnvironment2(env, sdk, {})) + await expect(bootstrapper.bootstrapEnvironment(env, sdk, {})) .rejects.toThrow('Not downgrading existing bootstrap stack'); }); @@ -101,7 +106,7 @@ describe('Bootstrapping v2', () => { template = args.stack.template; }); - await bootstrapEnvironment2(env, sdk, {}); + await bootstrapper.bootstrapEnvironment(env, sdk, {}); const exports = Object.values(template.Outputs ?? {}) .filter((o: any) => o.Export !== undefined) @@ -117,7 +122,7 @@ describe('Bootstrapping v2', () => { }); test('stack is not termination protected by default', async () => { - await bootstrapEnvironment2(env, sdk); + await bootstrapper.bootstrapEnvironment(env, sdk); expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ stack: expect.objectContaining({ @@ -127,10 +132,8 @@ describe('Bootstrapping v2', () => { }); test('stack is termination protected when option is set', async () => { - await bootstrapEnvironment2(env, sdk, { - parameters: { - terminationProtection: true, - }, + await bootstrapper.bootstrapEnvironment(env, sdk, { + terminationProtection: true, }); expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/packages/aws-cdk/test/cdk-toolkit.test.ts b/packages/aws-cdk/test/cdk-toolkit.test.ts index 8847d22ae32fd..72320055571c1 100644 --- a/packages/aws-cdk/test/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cdk-toolkit.test.ts @@ -1,20 +1,18 @@ -const mockBootstrapEnvironment = jest.fn(); -jest.mock('../lib/api/bootstrap', () => { - return { - bootstrapEnvironment: mockBootstrapEnvironment, - }; -}); - import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; +import { Bootstrapper } from '../lib/api/bootstrap'; import { CloudFormationDeployments, DeployStackOptions } from '../lib/api/cloudformation-deployments'; import { DeployStackResult } from '../lib/api/deploy-stack'; import { Template } from '../lib/api/util/cloudformation'; import { CdkToolkit, Tag } from '../lib/cdk-toolkit'; -import { MockCloudExecutable, TestStackArtifact } from './util'; +import { MockCloudExecutable, TestStackArtifact, classMockOf } from './util'; let cloudExecutable: MockCloudExecutable; +let bootstrapper: jest.Mocked; beforeEach(() => { + bootstrapper = classMockOf(Bootstrapper); + bootstrapper.bootstrapEnvironment.mockResolvedValue({ noOp: false, outputs: {} } as any); + cloudExecutable = new MockCloudExecutable({ stacks: [ MockStack.MOCK_STACK_A, @@ -22,7 +20,6 @@ beforeEach(() => { ], }); - mockBootstrapEnvironment.mockReset().mockResolvedValue({ noOp: false, outputs: {} }); }); function defaultToolkitSetup() { @@ -72,15 +69,15 @@ describe('deploy', () => { const toolkit = defaultToolkitSetup(); // WHEN - await toolkit.bootstrap(['aws://56789/south-pole'], undefined, undefined, false, false, {}); + await toolkit.bootstrap(['aws://56789/south-pole'], bootstrapper, {}); // THEN - expect(mockBootstrapEnvironment).toHaveBeenCalledWith({ + expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledWith({ account: '56789', region: 'south-pole', name: 'aws://56789/south-pole', }, expect.anything(), expect.anything()); - expect(mockBootstrapEnvironment).toHaveBeenCalledTimes(1); + expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledTimes(1); }); test('globby bootstrap uses whats in the stacks', async () => { @@ -89,15 +86,15 @@ describe('deploy', () => { cloudExecutable.configuration.settings.set(['app'], 'something'); // WHEN - await toolkit.bootstrap(['aws://*/bermuda-triangle-1'], undefined, undefined, false, false, {}); + await toolkit.bootstrap(['aws://*/bermuda-triangle-1'], bootstrapper, {}); // THEN - expect(mockBootstrapEnvironment).toHaveBeenCalledWith({ + expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledWith({ account: '123456789012', region: 'bermuda-triangle-1', name: 'aws://123456789012/bermuda-triangle-1', }, expect.anything(), expect.anything()); - expect(mockBootstrapEnvironment).toHaveBeenCalledTimes(1); + expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledTimes(1); }); test('bootstrap can be invoked without the --app argument', async () => { @@ -109,15 +106,15 @@ describe('deploy', () => { const toolkit = defaultToolkitSetup(); // WHEN - await toolkit.bootstrap(['aws://123456789012/west-pole'], undefined, undefined, false, false, {}); + await toolkit.bootstrap(['aws://123456789012/west-pole'], bootstrapper, {}); // THEN - expect(mockBootstrapEnvironment).toHaveBeenCalledWith({ + expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledWith({ account: '123456789012', region: 'west-pole', name: 'aws://123456789012/west-pole', }, expect.anything(), expect.anything()); - expect(mockBootstrapEnvironment).toHaveBeenCalledTimes(1); + expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledTimes(1); expect(cloudExecutable.hasApp).toEqual(false); expect(mockSynthesize).not.toHaveBeenCalled(); diff --git a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts index a5b8bd3562b6a..9ea4ea93754c2 100644 --- a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts @@ -1,5 +1,7 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { cloudFormation } from './aws-helpers'; -import { cdk, cdkDeploy, cleanup, fullStackName, prepareAppFixture, rememberToDeleteBucket } from './cdk-helpers'; +import { cdk, cdkDeploy, cleanup, fullStackName, prepareAppFixture, rememberToDeleteBucket, INTEG_TEST_DIR } from './cdk-helpers'; import { integTest } from './test-helpers'; jest.setTimeout(600_000); @@ -175,6 +177,34 @@ integTest('can create multiple legacy bootstrap stacks', async () => { ]); }); +integTest('can dump the template, modify and use it to deploy a custom bootstrap stack', async () => { + let template = await cdk(['bootstrap', '--show-template'], { + captureStderr: false, + modEnv: { + CDK_NEW_BOOTSTRAP: '1', + }, + }); + + expect(template).toContain('BootstrapVersion:'); + + template += '\n' + [ + ' TwiddleDee:', + ' Value: Template got twiddled', + ].join('\n'); + + const filename = path.join(INTEG_TEST_DIR, `${QUALIFIER}-template.yaml`); + fs.writeFileSync(filename, template, { encoding: 'utf-8' }); + await cdk(['bootstrap', + '--toolkit-stack-name', fullStackName('bootstrap-stack'), + '--qualifier', QUALIFIER, + '--template', filename, + '--cloudformation-execution-policies', 'arn:aws:iam::aws:policy/AdministratorAccess'], { + modEnv: { + CDK_NEW_BOOTSTRAP: '1', + }, + }); +}); + function randomString() { // Crazy return Math.random().toString(36).replace(/[^a-z0-9]+/g, ''); From c1aa4ca6a1596cd3133761e2c8ba3e02aecc3c6a Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 27 Aug 2020 14:35:28 +0200 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Nick Lynch --- packages/aws-cdk/README.md | 2 +- packages/aws-cdk/bin/cdk.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index e5d1e185e1b42..9a921a17b0365 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -253,7 +253,7 @@ $ cdk bootstrap --app='node bin/main.js' foo bar By default, bootstrap stack will be protected from stack termination. This can be disabled using `--termination-protection` argument. -Your company may require specific tweaks to the bootstrapping template. You can customize the template +If you have specific needs, policies, or requirements not met by the default template, you can customize it to fit your own situation, by exporting the default one to a file and either deploying it yourself using CloudFormation directly, or by telling the CLI to use a custom template. That looks as follows: diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 74925c9db71e8..8c8ff3a08426e 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -236,7 +236,7 @@ async function initCommandLine() { // new style stack synthesis has been configured in `cdk.json`. // // In code it's optimistically called "default" bootstrapping but that is in - // inticipation of flipping the switch, in user messaging we still call it + // anticipation of flipping the switch, in user messaging we still call it // "new" bootstrapping. let source: BootstrapSource = { source: 'legacy' }; if (args.template) { From 0608f396f4284e839e5559f4d62cb360c65f349f Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 31 Aug 2020 11:06:41 +0200 Subject: [PATCH 3/4] Rename mocking function --- packages/aws-cdk/test/cdk-toolkit.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/aws-cdk/test/cdk-toolkit.test.ts b/packages/aws-cdk/test/cdk-toolkit.test.ts index 72320055571c1..b2849c93da9ce 100644 --- a/packages/aws-cdk/test/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cdk-toolkit.test.ts @@ -5,12 +5,12 @@ import { CloudFormationDeployments, DeployStackOptions } from '../lib/api/cloudf import { DeployStackResult } from '../lib/api/deploy-stack'; import { Template } from '../lib/api/util/cloudformation'; import { CdkToolkit, Tag } from '../lib/cdk-toolkit'; -import { MockCloudExecutable, TestStackArtifact, classMockOf } from './util'; +import { MockCloudExecutable, TestStackArtifact, instanceMockFrom } from './util'; let cloudExecutable: MockCloudExecutable; let bootstrapper: jest.Mocked; beforeEach(() => { - bootstrapper = classMockOf(Bootstrapper); + bootstrapper = instanceMockFrom(Bootstrapper); bootstrapper.bootstrapEnvironment.mockResolvedValue({ noOp: false, outputs: {} } as any); cloudExecutable = new MockCloudExecutable({ From d7e23ed632b9d7147e81f637ccccee216a43d441 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 31 Aug 2020 11:24:11 +0200 Subject: [PATCH 4/4] Raise timeout for tests --- .../aws-custom-resource/aws-custom-resource-provider.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource-provider.test.ts b/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource-provider.test.ts index 44684ab4282e1..0a7e5ec220c28 100644 --- a/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource-provider.test.ts +++ b/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource-provider.test.ts @@ -6,6 +6,11 @@ import * as sinon from 'sinon'; import { AwsSdkCall, PhysicalResourceId } from '../../lib'; import { flatten, handler, forceSdkInstallation } from '../../lib/aws-custom-resource/runtime'; + +// This test performs an 'npm install' which may take longer than the default +// 5s timeout +jest.setTimeout(60_000); + /* eslint-disable no-console */ console.log = jest.fn();