Skip to content

Commit 3b251a5

Browse files
authored
feat(bootstrap): prevent accidental bootstrap overwrites (#24302)
A problem that sometimes happens today is that an account has a customized bootstrap stack setup, but a developer is not aware and they `cdk bootstrap` the customizations away with the CDK default template. This change introduces a new parameter, `BootstrapFlavor`, that an organization can change to a different value. The CDK CLI will refuse to overwrite existing bootstrap stacks of one flavor, with a different one. This string could have been in the template metadata, but making it a parameter puts it front and center, making it more obvious that template customizers should change it. Does not change the bootstrap stack version itself, since nothing about the resources inside changed. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent dee02a6 commit 3b251a5

File tree

9 files changed

+109
-15
lines changed

9 files changed

+109
-15
lines changed

packages/@aws-cdk-testing/cli-integ/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"p-queue": "^6.6.2",
5151
"semver": "^7.3.8",
5252
"ts-mock-imports": "^1.3.8",
53+
"yaml": "1.10.2",
5354
"yargs": "^17.7.0"
5455
},
5556
"repository": {

packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/bootstrapping.integtest.ts

+36
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from 'fs';
22
import * as path from 'path';
3+
import * as yaml from 'yaml';
34
import { integTest, randomString, withoutBootstrap } from '../../lib';
45

56
jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime
@@ -196,6 +197,41 @@ integTest('can dump the template, modify and use it to deploy a custom bootstrap
196197
});
197198
}));
198199

200+
integTest('a customized template vendor will not overwrite the default template', withoutBootstrap(async (fixture) => {
201+
// Initial bootstrap
202+
const toolkitStackName = fixture.bootstrapStackName;
203+
await fixture.cdkBootstrapModern({
204+
toolkitStackName,
205+
cfnExecutionPolicy: 'arn:aws:iam::aws:policy/AdministratorAccess',
206+
});
207+
208+
// Customize template
209+
const templateStr = await fixture.cdkBootstrapModern({
210+
// toolkitStackName doesn't matter for this particular invocation
211+
toolkitStackName,
212+
showTemplate: true,
213+
cliOptions: {
214+
captureStderr: false,
215+
},
216+
});
217+
218+
const template = yaml.parse(templateStr, { schema: 'core' });
219+
template.Parameters.BootstrapVariant.Default = 'CustomizedVendor';
220+
const filename = path.join(fixture.integTestDir, `${fixture.qualifier}-template.yaml`);
221+
fs.writeFileSync(filename, yaml.stringify(template, { schema: 'yaml-1.1' }), { encoding: 'utf-8' });
222+
223+
// Rebootstrap. For some reason, this doesn't cause a failure, it's a successful no-op.
224+
const output = await fixture.cdkBootstrapModern({
225+
toolkitStackName,
226+
template: filename,
227+
cfnExecutionPolicy: 'arn:aws:iam::aws:policy/AdministratorAccess',
228+
cliOptions: {
229+
captureStderr: true,
230+
},
231+
});
232+
expect(output).toContain('Not overwriting it with a template containing');
233+
}));
234+
199235
integTest('can use the default permissions boundary to bootstrap', withoutBootstrap(async (fixture) => {
200236
let template = await fixture.cdkBootstrapModern({
201237
// toolkitStackName doesn't matter for this particular invocation

packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ export const REPOSITORY_NAME_OUTPUT = 'RepositoryName';
55
export const BUCKET_DOMAIN_NAME_OUTPUT = 'BucketDomainName';
66
export const BOOTSTRAP_VERSION_OUTPUT = 'BootstrapVersion';
77
export const BOOTSTRAP_VERSION_RESOURCE = 'CdkBootstrapVersion';
8+
export const BOOTSTRAP_VARIANT_PARAMETER = 'BootstrapVariant';
9+
10+
/**
11+
* The assumed vendor of a template in case it is not set
12+
*/
13+
export const DEFAULT_BOOTSTRAP_VARIANT = 'AWS CDK: Default Resources';
814

915
/**
1016
* Options for the bootstrapEnvironment operation(s)

packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ Parameters:
5050
Default: 'false'
5151
AllowedValues: [ 'true', 'false' ]
5252
Type: String
53+
BootstrapVariant:
54+
Type: String
55+
Default: 'AWS CDK: Default Resources'
56+
Description: Describe the provenance of the resources in this bootstrap
57+
stack. Change this when you customize the template. To prevent accidents,
58+
the CDK CLI will not overwrite bootstrap stacks with a different variant.
5359
Conditions:
5460
HasTrustedAccounts:
5561
Fn::Not:

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

+29-12
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from 'path';
33
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
44
import * as cxapi from '@aws-cdk/cx-api';
55
import * as fs from 'fs-extra';
6-
import { BOOTSTRAP_VERSION_OUTPUT, BootstrapEnvironmentOptions, BOOTSTRAP_VERSION_RESOURCE } from './bootstrap-props';
6+
import { BOOTSTRAP_VERSION_OUTPUT, BootstrapEnvironmentOptions, BOOTSTRAP_VERSION_RESOURCE, BOOTSTRAP_VARIANT_PARAMETER, DEFAULT_BOOTSTRAP_VARIANT } from './bootstrap-props';
77
import * as logging from '../../logging';
88
import { Mode, SdkProvider, ISDK } from '../aws-auth';
99
import { deployStack, DeployStackResult } from '../deploy-stack';
@@ -63,21 +63,34 @@ export class BootstrapStack {
6363
parameters: Record<string, string | undefined>,
6464
options: Omit<BootstrapEnvironmentOptions, 'parameters'>,
6565
): Promise<DeployStackResult> {
66-
67-
const newVersion = bootstrapVersionFromTemplate(template);
68-
if (this.currentToolkitInfo.found && newVersion < this.currentToolkitInfo.version && !options.force) {
69-
logging.warning(`Bootstrap stack already at version '${this.currentToolkitInfo.version}'. Not downgrading it to version '${newVersion}' (use --force if you intend to downgrade)`);
70-
if (newVersion === 0) {
71-
// A downgrade with 0 as target version means we probably have a new-style bootstrap in the account,
72-
// and an old-style bootstrap as current target, which means the user probably forgot to put this flag in.
73-
logging.warning('(Did you set the \'@aws-cdk/core:newStyleStackSynthesis\' feature flag in cdk.json?)');
74-
}
75-
76-
return {
66+
if (this.currentToolkitInfo.found && !options.force) {
67+
// Safety checks
68+
const abortResponse = {
7769
noOp: true,
7870
outputs: {},
7971
stackArn: this.currentToolkitInfo.bootstrapStack.stackId,
8072
};
73+
74+
// Validate that the bootstrap stack we're trying to replace is from the same variant as the one we're trying to deploy
75+
const currentVariant = this.currentToolkitInfo.variant;
76+
const newVariant = bootstrapVariantFromTemplate(template);
77+
if (currentVariant !== newVariant) {
78+
logging.warning(`Bootstrap stack already exists, containing '${currentVariant}'. Not overwriting it with a template containing '${newVariant}' (use --force if you intend to overwrite)`);
79+
return abortResponse;
80+
}
81+
82+
// Validate that we're not downgrading the bootstrap stack
83+
const newVersion = bootstrapVersionFromTemplate(template);
84+
const currentVersion = this.currentToolkitInfo.version;
85+
if (newVersion < currentVersion) {
86+
logging.warning(`Bootstrap stack already at version ${currentVersion}. Not downgrading it to version ${newVersion} (use --force if you intend to downgrade)`);
87+
if (newVersion === 0) {
88+
// A downgrade with 0 as target version means we probably have a new-style bootstrap in the account,
89+
// and an old-style bootstrap as current target, which means the user probably forgot to put this flag in.
90+
logging.warning('(Did you set the \'@aws-cdk/core:newStyleStackSynthesis\' feature flag in cdk.json?)');
91+
}
92+
return abortResponse;
93+
}
8194
}
8295

8396
const outdir = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-bootstrap'));
@@ -127,3 +140,7 @@ export function bootstrapVersionFromTemplate(template: any): number {
127140
}
128141
return 0;
129142
}
143+
144+
export function bootstrapVariantFromTemplate(template: any): string {
145+
return template.Parameters?.[BOOTSTRAP_VARIANT_PARAMETER]?.Default ?? DEFAULT_BOOTSTRAP_VARIANT;
146+
}

packages/aws-cdk/lib/api/toolkit-info.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as cxapi from '@aws-cdk/cx-api';
22
import * as chalk from 'chalk';
33
import { ISDK } from './aws-auth';
4-
import { BOOTSTRAP_VERSION_OUTPUT, BUCKET_DOMAIN_NAME_OUTPUT, BUCKET_NAME_OUTPUT } from './bootstrap/bootstrap-props';
4+
import { BOOTSTRAP_VERSION_OUTPUT, BUCKET_DOMAIN_NAME_OUTPUT, BUCKET_NAME_OUTPUT, BOOTSTRAP_VARIANT_PARAMETER, DEFAULT_BOOTSTRAP_VARIANT } from './bootstrap/bootstrap-props';
55
import { stabilizeStack, CloudFormationStack } from './util/cloudformation';
66
import { debug, warning } from '../logging';
77

@@ -102,6 +102,7 @@ export abstract class ToolkitInfo {
102102
public abstract readonly bucketUrl: string;
103103
public abstract readonly bucketName: string;
104104
public abstract readonly version: number;
105+
public abstract readonly variant: string;
105106
public abstract readonly bootstrapStack: CloudFormationStack;
106107

107108
constructor(protected readonly sdk: ISDK) {
@@ -132,6 +133,10 @@ class ExistingToolkitInfo extends ToolkitInfo {
132133
return parseInt(this.bootstrapStack.outputs[BOOTSTRAP_VERSION_OUTPUT] ?? '0', 10);
133134
}
134135

136+
public get variant() {
137+
return this.bootstrapStack.parameters[BOOTSTRAP_VARIANT_PARAMETER] ?? DEFAULT_BOOTSTRAP_VARIANT;
138+
}
139+
135140
public get parameters(): Record<string, string> {
136141
return this.bootstrapStack.parameters ?? {};
137142
}
@@ -258,6 +263,10 @@ class BootstrapStackNotFoundInfo extends ToolkitInfo {
258263
throw new Error(this.errorMessage);
259264
}
260265

266+
public get variant(): string {
267+
throw new Error(this.errorMessage);
268+
}
269+
261270
public async validateVersion(expectedVersion: number, ssmParameterName: string | undefined): Promise<void> {
262271
if (ssmParameterName === undefined) {
263272
throw new Error(this.errorMessage);

packages/aws-cdk/test/api/bootstrap2.test.ts

+15
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,21 @@ describe('Bootstrapping v2', () => {
299299
})).resolves.toEqual(expect.objectContaining({ noOp: true }));
300300
});
301301

302+
test('Do not allow overwriting bootstrap stack from a different vendor', async () => {
303+
// GIVEN
304+
mockTheToolkitInfo({
305+
Parameters: [
306+
{
307+
ParameterKey: 'BootstrapVariant',
308+
ParameterValue: 'JoeSchmoe',
309+
},
310+
],
311+
});
312+
313+
await expect(bootstrapper.bootstrapEnvironment(env, sdk, {
314+
})).resolves.toEqual(expect.objectContaining({ noOp: true }));
315+
});
316+
302317
test('bootstrap template has the right exports', async () => {
303318
let template: any;
304319
mockDeployStack.mockImplementation((args: DeployStackOptions) => {

packages/aws-cdk/test/api/cloudformation-deployments.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema';
88
import * as cxapi from '@aws-cdk/cx-api';
99
import { CloudFormation } from 'aws-sdk';
1010
import { FakeCloudformationStack } from './fake-cloudformation-stack';
11+
import { DEFAULT_BOOTSTRAP_VARIANT } from '../../lib';
1112
import { CloudFormationDeployments } from '../../lib/api/cloudformation-deployments';
1213
import { deployStack } from '../../lib/api/deploy-stack';
1314
import { HotswapMode } from '../../lib/api/hotswap/common';
@@ -931,6 +932,7 @@ function testStackWithAssetManifest() {
931932
public found: boolean = true;
932933
public bucketUrl: string = 's3://fake/here';
933934
public bucketName: string = 'fake';
935+
public variant: string = DEFAULT_BOOTSTRAP_VARIANT;
934936
public version: number = 1234;
935937
public get bootstrapStack(): CloudFormationStack {
936938
throw new Error('This should never happen');
@@ -1000,4 +1002,4 @@ function testStackWithAssetManifest() {
10001002

10011003
const assembly = builder.buildAssembly();
10021004
return assembly.getStackArtifact('stack');
1003-
}
1005+
}

packages/aws-cdk/test/util/mock-toolkitinfo.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ISDK, ToolkitInfo } from '../../lib/api';
1+
import { ISDK, ToolkitInfo, DEFAULT_BOOTSTRAP_VARIANT } from '../../lib/api';
22
import { CloudFormationStack } from '../../lib/api/util/cloudformation';
33

44
export interface MockToolkitInfoProps {
@@ -17,6 +17,7 @@ export class MockToolkitInfo extends ToolkitInfo {
1717
public readonly bucketUrl: string;
1818
public readonly bucketName: string;
1919
public readonly version: number;
20+
public readonly variant: string;
2021
public readonly prepareEcrRepository = mockLike<typeof ToolkitInfo.prototype.prepareEcrRepository>();
2122

2223
private readonly _bootstrapStack?: CloudFormationStack;
@@ -27,6 +28,7 @@ export class MockToolkitInfo extends ToolkitInfo {
2728
this.bucketName = props.bucketName ?? 'MockToolkitBucketName';
2829
this.bucketUrl = props.bucketUrl ?? `https://${this.bucketName}.s3.amazonaws.com/`;
2930
this.version = props.version ?? 1;
31+
this.variant = DEFAULT_BOOTSTRAP_VARIANT;
3032
this._bootstrapStack = props.bootstrapStack;
3133
}
3234

0 commit comments

Comments
 (0)