diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts index cea67ab76263d..51c19bf226a96 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts @@ -62,6 +62,21 @@ export interface AwsCloudFormationStackProperties { * @default - No bootstrap stack required */ readonly requiresBootstrapStackVersion?: number; + + /** + * SSM parameter where the bootstrap stack version number can be found + * + * Only used if `requiresBootstrapStackVersion` is set. + * + * - If this value is not set, the bootstrap stack name must be known at + * deployment time so the stack version can be looked up from the stack + * outputs. + * - If this value is set, the bootstrap stack can have any name because + * we won't need to look it up. + * + * @default - Bootstrap stack version number looked up + */ + readonly bootstrapStackVersionSsmParameter?: string; } /** @@ -79,6 +94,19 @@ export interface AssetManifestProperties { * @default - Version 1 (basic modern bootstrap stack) */ readonly requiresBootstrapStackVersion?: number; + + /** + * SSM parameter where the bootstrap stack version number can be found + * + * - If this value is not set, the bootstrap stack name must be known at + * deployment time so the stack version can be looked up from the stack + * outputs. + * - If this value is set, the bootstrap stack can have any name because + * we won't need to look it up. + * + * @default - Bootstrap stack version number looked up + */ + readonly bootstrapStackVersionSsmParameter?: string; } /** diff --git a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json index 99fbaedb6c416..2ed400edff3ca 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json +++ b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json @@ -306,6 +306,10 @@ "requiresBootstrapStackVersion": { "description": "Version of bootstrap stack required to deploy this stack (Default - No bootstrap stack required)", "type": "number" + }, + "bootstrapStackVersionSsmParameter": { + "description": "SSM parameter where the bootstrap stack version number can be found\n\nOnly used if `requiresBootstrapStackVersion` is set.\n\n- If this value is not set, the bootstrap stack name must be known at\n deployment time so the stack version can be looked up from the stack\n outputs.\n- If this value is set, the bootstrap stack can have any name because\n we won't need to look it up. (Default - Bootstrap stack version number looked up)", + "type": "string" } }, "required": [ @@ -323,6 +327,10 @@ "requiresBootstrapStackVersion": { "description": "Version of bootstrap stack required to deploy this stack (Default - Version 1 (basic modern bootstrap stack))", "type": "number" + }, + "bootstrapStackVersionSsmParameter": { + "description": "SSM parameter where the bootstrap stack version number can be found\n\n- If this value is not set, the bootstrap stack name must be known at\n deployment time so the stack version can be looked up from the stack\n outputs.\n- If this value is set, the bootstrap stack can have any name because\n we won't need to look it up. (Default - Bootstrap stack version number looked up)", + "type": "string" } }, "required": [ diff --git a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json index e6bb766b23585..193f97fba499d 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json +++ b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json @@ -1 +1 @@ -{"version":"8.0.0"} +{"version":"9.0.0"} diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts index f90ae86dbf584..b4f9f29cf732a 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts @@ -389,6 +389,7 @@ export class DefaultStackSynthesizer extends StackSynthesizer { cloudFormationExecutionRoleArn: this._cloudFormationExecutionRoleArn, stackTemplateAssetObjectUrl: templateManifestUrl, requiresBootstrapStackVersion: MIN_BOOTSTRAP_STACK_VERSION, + bootstrapStackVersionSsmParameter: `/cdk-bootstrap/${this.qualifier}/version`, additionalDependencies: [artifactId], }); } @@ -472,6 +473,7 @@ export class DefaultStackSynthesizer extends StackSynthesizer { properties: { file: manifestFile, requiresBootstrapStackVersion: MIN_BOOTSTRAP_STACK_VERSION, + bootstrapStackVersionSsmParameter: `/cdk-bootstrap/${this.qualifier}/version`, }, }); diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts index fde6ed053059e..1a43f9da11316 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts @@ -106,4 +106,19 @@ export interface SynthesizeStackArtifactOptions { * @default - No bootstrap stack required */ readonly requiresBootstrapStackVersion?: number; + + /** + * SSM parameter where the bootstrap stack version number can be found + * + * Only used if `requiresBootstrapStackVersion` is set. + * + * - If this value is not set, the bootstrap stack name must be known at + * deployment time so the stack version can be looked up from the stack + * outputs. + * - If this value is set, the bootstrap stack can have any name because + * we won't need to look it up. + * + * @default - Bootstrap stack version number looked up + */ + readonly bootstrapStackVersionSsmParameter?: string; } \ No newline at end of file diff --git a/packages/@aws-cdk/core/test/stack-synthesis/new-style-synthesis.test.ts b/packages/@aws-cdk/core/test/stack-synthesis/new-style-synthesis.test.ts index d5e04c65018a0..0131b7a4acc63 100644 --- a/packages/@aws-cdk/core/test/stack-synthesis/new-style-synthesis.test.ts +++ b/packages/@aws-cdk/core/test/stack-synthesis/new-style-synthesis.test.ts @@ -149,11 +149,15 @@ nodeunitShim({ const asm = app.synth(); // THEN - we have an asset manifest with both assets and the stack template in there - const manifest = readAssetManifest(asm); + const manifestArtifact = getAssetManifest(asm); + const manifest = readAssetManifest(manifestArtifact); test.equals(Object.keys(manifest.files || {}).length, 2); test.equals(Object.keys(manifest.dockerImages || {}).length, 1); + // THEN - the asset manifest has an SSM parameter entry + expect(manifestArtifact.bootstrapStackVersionSsmParameter).toEqual('/cdk-bootstrap/hnb659fds/version'); + // THEN - every artifact has an assumeRoleArn for (const file of Object.values(manifest.files ?? {})) { for (const destination of Object.values(file.destinations)) { @@ -200,7 +204,7 @@ nodeunitShim({ // THEN const asm = myapp.synth(); - const manifest = readAssetManifest(asm); + const manifest = readAssetManifest(getAssetManifest(asm)); test.deepEqual(manifest.files?.['file-asset-hash']?.destinations?.['current_account-current_region'], { bucketName: 'file-asset-bucket', @@ -247,7 +251,7 @@ nodeunitShim({ const stackArtifact = asm.getStackArtifact('mystack-bucketPrefix'); // THEN - we have an asset manifest with both assets and the stack template in there - const manifest = readAssetManifest(asm); + const manifest = readAssetManifest(getAssetManifest(asm)); // THEN test.deepEqual(manifest.files?.['file-asset-hash-with-prefix']?.destinations?.['current_account-current_region'], { @@ -299,10 +303,13 @@ function isAssetManifest(x: cxapi.CloudArtifact): x is cxapi.AssetManifestArtifa return x instanceof cxapi.AssetManifestArtifact; } -function readAssetManifest(asm: cxapi.CloudAssembly): cxschema.AssetManifest { +function getAssetManifest(asm: cxapi.CloudAssembly): cxapi.AssetManifestArtifact { const manifestArtifact = asm.artifacts.filter(isAssetManifest)[0]; if (!manifestArtifact) { throw new Error('no asset manifest in assembly'); } + return manifestArtifact; +} +function readAssetManifest(manifestArtifact: cxapi.AssetManifestArtifact): cxschema.AssetManifest { return JSON.parse(fs.readFileSync(manifestArtifact.file, { encoding: 'utf-8' })); } diff --git a/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts b/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts index 8d79c92e89bb9..3c8c102f2c5ab 100644 --- a/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts @@ -17,6 +17,13 @@ export class AssetManifestArtifact extends CloudArtifact { */ public readonly requiresBootstrapStackVersion: number; + /** + * Name of SSM parameter with bootstrap stack version + * + * @default - Discover SSM parameter by reading stack + */ + public readonly bootstrapStackVersionSsmParameter?: string; + constructor(assembly: CloudAssembly, name: string, artifact: cxschema.ArtifactManifest) { super(assembly, name, artifact); @@ -26,5 +33,6 @@ export class AssetManifestArtifact extends CloudArtifact { } this.file = path.resolve(this.assembly.directory, properties.file); this.requiresBootstrapStackVersion = properties.requiresBootstrapStackVersion ?? 1; + this.bootstrapStackVersionSsmParameter = properties.bootstrapStackVersionSsmParameter; } } diff --git a/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts b/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts index 807e58f1a411c..56093d702d2e0 100644 --- a/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts @@ -82,6 +82,13 @@ export class CloudFormationStackArtifact extends CloudArtifact { */ public readonly requiresBootstrapStackVersion?: number; + /** + * Name of SSM parameter with bootstrap stack version + * + * @default - Discover SSM parameter by reading stack + */ + public readonly bootstrapStackVersionSsmParameter?: string; + /** * Whether termination protection is enabled for this stack. */ @@ -110,6 +117,7 @@ export class CloudFormationStackArtifact extends CloudArtifact { this.cloudFormationExecutionRoleArn = properties.cloudFormationExecutionRoleArn; this.stackTemplateAssetObjectUrl = properties.stackTemplateAssetObjectUrl; this.requiresBootstrapStackVersion = properties.requiresBootstrapStackVersion; + this.bootstrapStackVersionSsmParameter = properties.bootstrapStackVersionSsmParameter; this.terminationProtection = properties.terminationProtection; this.stackName = properties.stackName || artifactId; diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index d14e53892354a..a5c6e04d39f1c 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -143,8 +143,10 @@ async function initCommandLine() { debug('Command line arguments:', argv); const configuration = new Configuration({ - ...argv, - _: argv._ as [Command, ...string[]], // TypeScript at its best + commandLineArguments: { + ...argv, + _: argv._ as [Command, ...string[]], // TypeScript at its best + }, }); await configuration.load(); diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk.ts b/packages/aws-cdk/lib/api/aws-auth/sdk.ts index 3644e61189dc6..891ced65451b8 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk.ts @@ -218,9 +218,9 @@ export class SDK implements ISDK { // do additional things to errors. return Object.assign(Object.create(response), { promise() { - return response.promise().catch((e: Error) => { + return response.promise().catch((e: Error & { code?: string }) => { e = self.makeDetailedException(e); - debug(`Call failed: ${prop}(${JSON.stringify(args[0])}) => ${e.message}`); + debug(`Call failed: ${prop}(${JSON.stringify(args[0])}) => ${e.message} (code=${e.code})`); return Promise.reject(e); // Re-'throw' the new error }); }, diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml b/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml index f7a29c4c5d094..ee30856453ff9 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml @@ -353,6 +353,12 @@ Resources: - sts:GetCallerIdentity Resource: "*" Effect: Allow + - Sid: ReadVersion + Effect: Allow + Action: + - ssm:GetParameter + Resource: + - Fn::Sub: "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CdkBootstrapVersion}" Version: '2012-10-17' PolicyName: default RoleName: @@ -387,7 +393,7 @@ Resources: Type: String Name: Fn::Sub: '/cdk-bootstrap/${Qualifier}/version' - Value: '4' + Value: '5' Outputs: BucketName: Description: The name of the S3 bucket owned by the CDK toolkit stack diff --git a/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts b/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts index c29b1c21790a4..4d66e59268dcc 100644 --- a/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts +++ b/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts @@ -21,11 +21,6 @@ import { BOOTSTRAP_VERSION_OUTPUT, BootstrapEnvironmentOptions, BOOTSTRAP_VERSIO * * And do something in between the two phases (such as look at the * current bootstrap stack and doing something intelligent). - * - * This class is different from `ToolkitInfo` in that `ToolkitInfo` - * is purely read-only, and `ToolkitInfo.lookup()` returns `undefined` - * if the stack does not exist. But honestly, these classes could and - * should probably be merged at some point. */ export class BootstrapStack { public static async lookup(sdkProvider: SdkProvider, environment: cxapi.Environment, toolkitStackName?: string) { @@ -33,6 +28,7 @@ export class BootstrapStack { const resolvedEnvironment = await sdkProvider.resolveEnvironment(environment); const sdk = await sdkProvider.forEnvironment(resolvedEnvironment, Mode.ForWriting); + const currentToolkitInfo = await ToolkitInfo.lookup(resolvedEnvironment, sdk, toolkitStackName); return new BootstrapStack(sdkProvider, sdk, resolvedEnvironment, toolkitStackName, currentToolkitInfo); @@ -43,15 +39,15 @@ export class BootstrapStack { private readonly sdk: ISDK, private readonly resolvedEnvironment: cxapi.Environment, private readonly toolkitStackName: string, - private readonly currentToolkitInfo?: ToolkitInfo) { + private readonly currentToolkitInfo: ToolkitInfo) { } public get parameters(): Record { - return this.currentToolkitInfo?.parameters ?? {}; + return this.currentToolkitInfo.found ? this.currentToolkitInfo.bootstrapStack.parameters : {}; } public get terminationProtection() { - return this.currentToolkitInfo?.stack?.terminationProtection; + return this.currentToolkitInfo.found ? this.currentToolkitInfo.bootstrapStack.terminationProtection : undefined; } public async partition(): Promise { @@ -68,7 +64,7 @@ export class BootstrapStack { ): Promise { const newVersion = bootstrapVersionFromTemplate(template); - if (this.currentToolkitInfo && newVersion < this.currentToolkitInfo.version && !options.force) { + if (this.currentToolkitInfo.found && newVersion < this.currentToolkitInfo.version && !options.force) { throw new Error(`Not downgrading existing bootstrap stack from version '${this.currentToolkitInfo.version}' to version '${newVersion}'. Use --force to force.`); } @@ -99,6 +95,8 @@ export class BootstrapStack { execute: options.execute, parameters, usePreviousParameters: true, + // Obviously we can't need a bootstrap stack to deploy a bootstrap stack + toolkitInfo: ToolkitInfo.bootstraplessDeploymentsOnly(this.sdk), }); } } diff --git a/packages/aws-cdk/lib/api/cloudformation-deployments.ts b/packages/aws-cdk/lib/api/cloudformation-deployments.ts index 35f7fdcf4e7a4..2eec90afdb790 100644 --- a/packages/aws-cdk/lib/api/cloudformation-deployments.ts +++ b/packages/aws-cdk/lib/api/cloudformation-deployments.ts @@ -154,7 +154,11 @@ export class CloudFormationDeployments { await this.publishStackAssets(options.stack, toolkitInfo); // Do a verification of the bootstrap stack version - this.validateBootstrapStackVersion(options.stack.stackName, options.stack.requiresBootstrapStackVersion, toolkitInfo); + await this.validateBootstrapStackVersion( + options.stack.stackName, + options.stack.requiresBootstrapStackVersion, + options.stack.bootstrapStackVersionSsmParameter, + toolkitInfo); return deployStack({ stack: options.stack, @@ -251,12 +255,16 @@ export class CloudFormationDeployments { /** * Publish all asset manifests that are referenced by the given stack */ - private async publishStackAssets(stack: cxapi.CloudFormationStackArtifact, bootstrapStack: ToolkitInfo | undefined) { + 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) { - this.validateBootstrapStackVersion(stack.stackName, assetArtifact.requiresBootstrapStackVersion, bootstrapStack); + await this.validateBootstrapStackVersion( + stack.stackName, + assetArtifact.requiresBootstrapStackVersion, + assetArtifact.bootstrapStackVersionSsmParameter, + toolkitInfo); const manifest = AssetManifest.fromFile(assetArtifact.file); await publishAssets(manifest, this.sdkProvider, stackEnv); @@ -266,19 +274,22 @@ export class CloudFormationDeployments { /** * Validate that the bootstrap stack has the right version for this stack */ - private validateBootstrapStackVersion( + private async validateBootstrapStackVersion( stackName: string, requiresBootstrapStackVersion: number | undefined, - bootstrapStack: ToolkitInfo | undefined) { + bootstrapStackVersionSsmParameter: string | undefined, + toolkitInfo: ToolkitInfo) { if (requiresBootstrapStackVersion === undefined) { return; } - if (!bootstrapStack) { + if (!toolkitInfo.found) { throw new Error(`${stackName}: publishing assets requires bootstrap stack version '${requiresBootstrapStackVersion}', no bootstrap stack found. Please run 'cdk bootstrap'.`); } - if (requiresBootstrapStackVersion > bootstrapStack.version) { - throw new Error(`${stackName}: publishing assets requires bootstrap stack version '${requiresBootstrapStackVersion}', found '${bootstrapStack.version}'. Please run 'cdk bootstrap' with a newer CLI version.`); + try { + await toolkitInfo.validateVersion(requiresBootstrapStackVersion, bootstrapStackVersionSsmParameter); + } catch (e) { + throw new Error(`${stackName}: ${e.message}`); } } } diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index b13582ce31623..1b52fb736e946 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -79,10 +79,8 @@ export interface DeployStackOptions { /** * Information about the bootstrap stack found in the target environment - * - * @default - Assume there is no bootstrap stack */ - toolkitInfo?: ToolkitInfo; + toolkitInfo: ToolkitInfo; /** * Role to pass to CloudFormation to execute the change set @@ -313,7 +311,7 @@ async function makeBodyParameter( stack: cxapi.CloudFormationStackArtifact, resolvedEnvironment: cxapi.Environment, assetManifest: AssetManifestBuilder, - toolkitInfo?: ToolkitInfo): Promise { + toolkitInfo: ToolkitInfo): Promise { // If the template has already been uploaded to S3, just use it from there. if (stack.stackTemplateAssetObjectUrl) { @@ -327,7 +325,7 @@ async function makeBodyParameter( return { TemplateBody: templateJson }; } - if (!toolkitInfo) { + if (!toolkitInfo.found) { error( `The template for stack "${stack.displayName}" is ${Math.round(templateJson.length / 1024)}KiB. ` + `Templates larger than ${LARGE_TEMPLATE_SIZE_KB}KiB must be uploaded to S3.\n` + diff --git a/packages/aws-cdk/lib/api/toolkit-info.ts b/packages/aws-cdk/lib/api/toolkit-info.ts index 0b0dd0f288e95..014a08c670619 100644 --- a/packages/aws-cdk/lib/api/toolkit-info.ts +++ b/packages/aws-cdk/lib/api/toolkit-info.ts @@ -1,6 +1,6 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as colors from 'colors/safe'; -import { debug } from '../logging'; +import { debug, warning } from '../logging'; import { ISDK } from './aws-auth'; import { BOOTSTRAP_VERSION_OUTPUT, BUCKET_DOMAIN_NAME_OUTPUT, BUCKET_NAME_OUTPUT } from './bootstrap'; import { stabilizeStack, CloudFormationStack } from './util/cloudformation'; @@ -8,37 +8,119 @@ import { stabilizeStack, CloudFormationStack } from './util/cloudformation'; export const DEFAULT_TOOLKIT_STACK_NAME = 'CDKToolkit'; /** - * Information on the Bootstrap stack + * The bootstrap template version that introduced ssm:GetParameter + */ +const BOOTSTRAP_TEMPLATE_VERSION_INTRODUCING_GETPARAMETER = 5; + +/** + * Information on the Bootstrap stack of the environment we're deploying to. + * + * This class serves to: + * + * - Inspect the bootstrap stack, and return various properties of it for successful + * asset deployment (in case of legacy-synthesized stacks). + * - Validate the version of the target environment, and nothing else (in case of + * default-synthesized stacks). + * + * An object of this type might represent a bootstrap stack that could not be found. + * This is not an issue unless any members are used that require the bootstrap stack + * to have been found, in which case an error is thrown (default-synthesized stacks + * should never run into this as they don't need information from the bootstrap + * stack, all information is already encoded into the Cloud Assembly Manifest). + * + * Nevertheless, an instance of this class exists to serve as a cache for SSM + * parameter lookups (otherwise, the "bootstrap stack version" parameter would + * need to be read repeatedly). * * Called "ToolkitInfo" for historical reasons. * * @experimental */ -export class ToolkitInfo { +export abstract class ToolkitInfo { public static determineName(overrideName?: string) { return overrideName ?? DEFAULT_TOOLKIT_STACK_NAME; } /** @experimental */ - public static async lookup(environment: cxapi.Environment, sdk: ISDK, stackName: string | undefined): Promise { + public static async lookup(environment: cxapi.Environment, sdk: ISDK, stackName: string | undefined): Promise { const cfn = sdk.cloudFormation(); const stack = await stabilizeStack(cfn, stackName ?? DEFAULT_TOOLKIT_STACK_NAME); if (!stack) { debug('The environment %s doesn\'t have the CDK toolkit stack (%s) installed. Use %s to setup your environment for use with the toolkit.', environment.name, stackName, colors.blue(`cdk bootstrap "${environment.name}"`)); - return undefined; + return ToolkitInfo.bootstrapStackNotFoundInfo(sdk); } if (stack.stackStatus.isCreationFailure) { // Treat a "failed to create" bootstrap stack as an absent one. debug('The environment %s has a CDK toolkit stack (%s) that failed to create. Use %s to try provisioning it again.', environment.name, stackName, colors.blue(`cdk bootstrap "${environment.name}"`)); - return undefined; + return ToolkitInfo.bootstrapStackNotFoundInfo(sdk); } - return new ToolkitInfo(stack, sdk); + return new ExistingToolkitInfo(stack, sdk); + } + + public static fromStack(stack: CloudFormationStack, sdk: ISDK): ToolkitInfo { + return new ExistingToolkitInfo(stack, sdk); + } + + public static bootstraplessDeploymentsOnly(sdk: ISDK): ToolkitInfo { + return new BootstrapStackNotFoundInfo(sdk, 'Trying to perform an operation that requires a bootstrap stack; you should not see this error, this is a bug in the CDK CLI.'); + } + + public static bootstrapStackNotFoundInfo(sdk: ISDK): ToolkitInfo { + return new BootstrapStackNotFoundInfo(sdk, 'This deployment requires a bootstrap stack with a known name; pass \'--toolkit-stack-name\' or switch to using the \'DefaultStackSynthesizer\' (see https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html)'); } - constructor(public readonly stack: CloudFormationStack, private readonly sdk?: ISDK) { + public abstract readonly found: boolean; + public abstract readonly bucketUrl: string; + public abstract readonly bucketName: string; + public abstract readonly version: number; + public abstract readonly bootstrapStack: CloudFormationStack; + + private readonly ssmCache = new Map(); + + constructor(protected readonly sdk: ISDK) { + } + public abstract validateVersion(expectedVersion: number, ssmParameterName: string | undefined): Promise; + public abstract prepareEcrRepository(repositoryName: string): Promise; + + /** + * Read a version from an SSM parameter, cached + */ + protected async versionFromSsmParameter(parameterName: string): Promise { + const existing = this.ssmCache.get(parameterName); + if (existing !== undefined) { return existing; } + + const ssm = this.sdk.ssm(); + + try { + const result = await ssm.getParameter({ Name: parameterName }).promise(); + + const asNumber = parseInt(`${result.Parameter?.Value}`, 10); + if (isNaN(asNumber)) { + throw new Error(`SSM parameter ${parameterName} not a number: ${result.Parameter?.Value}`); + } + + this.ssmCache.set(parameterName, asNumber); + return asNumber; + } catch (e) { + if (e.code === 'ParameterNotFound') { + throw new Error(`SSM parameter ${parameterName} not found. Has the environment been bootstrapped? Please run \'cdk bootstrap\' (see https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html)`); + } + throw e; + } + } +} + +/** + * Returned when a bootstrap stack is found + */ +class ExistingToolkitInfo extends ToolkitInfo { + public readonly found = true; + + constructor(public readonly bootstrapStack: CloudFormationStack, sdk: ISDK) { + super(sdk); } public get bucketUrl() { @@ -50,11 +132,54 @@ export class ToolkitInfo { } public get version() { - return parseInt(this.stack.outputs[BOOTSTRAP_VERSION_OUTPUT] ?? '0', 10); + return parseInt(this.bootstrapStack.outputs[BOOTSTRAP_VERSION_OUTPUT] ?? '0', 10); } public get parameters(): Record { - return this.stack.parameters ?? {}; + return this.bootstrapStack.parameters ?? {}; + } + + public get terminationProtection(): boolean { + return this.bootstrapStack.terminationProtection ?? false; + } + + /** + * Validate that the bootstrap stack version matches or exceeds the expected version + * + * Use the SSM parameter name to read the version number if given, otherwise use the version + * discovered on the bootstrap stack. + * + * Pass in the SSM parameter name so we can cache the lookups an don't need to do the same + * lookup again and again for every artifact. + */ + public async validateVersion(expectedVersion: number, ssmParameterName: string | undefined) { + let version = this.version; // Default to the current version, but will be overwritten by a lookup if required. + + if (ssmParameterName !== undefined) { + try { + version = await this.versionFromSsmParameter(ssmParameterName); + } catch (e) { + if (e.code !== 'AccessDeniedException') { throw e; } + + // This is a fallback! The bootstrap template that goes along with this change introduces + // a new 'ssm:GetParameter' permission, but when run using the previous bootstrap template we + // won't have the permissions yet to read the version, so we won't be able to show the + // message telling the user they need to update! When we see an AccessDeniedException, fall + // back to the version we read from Stack Outputs; but ONLY if the version we discovered via + // outputs is legitimately an old version. If it's newer than that, something else must be broken, + // so let it fail as it would if we didn't have this fallback. + if (this.version >= BOOTSTRAP_TEMPLATE_VERSION_INTRODUCING_GETPARAMETER) { + throw e; + } + + warning(`Could not read SSM parameter ${ssmParameterName}: ${e.message}`); + // Fall through on purpose + } + } + + if (expectedVersion > version) { + throw new Error(`This CDK deployment requires bootstrap stack version '${expectedVersion}', found '${version}'. Please run 'cdk bootstrap'.`); + } } /** @@ -97,10 +222,73 @@ export class ToolkitInfo { } private requireOutput(output: string): string { - if (!(output in this.stack.outputs)) { - throw new Error(`The CDK toolkit stack (${this.stack.stackName}) does not have an output named ${output}. Use 'cdk bootstrap' to correct this.`); + if (!(output in this.bootstrapStack.outputs)) { + throw new Error(`The CDK toolkit stack (${this.bootstrapStack.stackName}) does not have an output named ${output}. Use 'cdk bootstrap' to correct this.`); } - return this.stack.outputs[output]; + return this.bootstrapStack.outputs[output]; + } +} + +/** + * Returned when a bootstrap stack could not be found + * + * This is not an error in principle, UNTIL one of the members is called that requires + * the bootstrap stack to have been found, in which case the lookup error is still thrown + * belatedly. + * + * The errors below serve as a last stop-gap message--normally calling code should have + * checked `toolkit.found` and produced an appropriate error message. + */ +class BootstrapStackNotFoundInfo extends ToolkitInfo { + public readonly found = false; + + constructor(sdk: ISDK, private readonly errorMessage: string) { + super(sdk); + } + + public get bootstrapStack(): CloudFormationStack { + throw new Error(this.errorMessage); + } + + public get bucketUrl(): string { + throw new Error(this.errorMessage); + } + + public get bucketName(): string { + throw new Error(this.errorMessage); + } + + public get version(): number { + throw new Error(this.errorMessage); + } + + public async validateVersion(expectedVersion: number, ssmParameterName: string | undefined): Promise { + if (ssmParameterName === undefined) { + throw new Error(this.errorMessage); + } + + let version: number; + try { + version = await this.versionFromSsmParameter(ssmParameterName); + } catch (e) { + if (e.code !== 'AccessDeniedException') { throw e; } + + // This is a fallback! The bootstrap template that goes along with this change introduces + // a new 'ssm:GetParameter' permission, but when run using a previous bootstrap template we + // won't have the permissions yet to read the version, so we won't be able to show the + // message telling the user they need to update! When we see an AccessDeniedException, fall + // back to the version we read from Stack Outputs. + warning(`Could not read SSM parameter ${ssmParameterName}: ${e.message}`); + throw new Error(`This CDK deployment requires bootstrap stack version '${expectedVersion}', found an older version. Please run 'cdk bootstrap'.`); + } + + if (expectedVersion > version) { + throw new Error(`This CDK deployment requires bootstrap stack version '${expectedVersion}', found '${version}'. Please run 'cdk bootstrap'.`); + } + } + + public prepareEcrRepository(): Promise { + throw new Error(this.errorMessage); } } @@ -114,4 +302,4 @@ export interface EcrCredentials { username: string; password: string; endpoint: string; -} +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/assets.ts b/packages/aws-cdk/lib/assets.ts index 1bc390ee4b969..36daa5586861a 100644 --- a/packages/aws-cdk/lib/assets.ts +++ b/packages/aws-cdk/lib/assets.ts @@ -14,7 +14,7 @@ import { AssetManifestBuilder } from './util/asset-manifest-builder'; * pass Asset coordinates. */ // eslint-disable-next-line max-len -export async function addMetadataAssetsToManifest(stack: cxapi.CloudFormationStackArtifact, assetManifest: AssetManifestBuilder, toolkitInfo?: ToolkitInfo, reuse?: string[]): Promise> { +export async function addMetadataAssetsToManifest(stack: cxapi.CloudFormationStackArtifact, assetManifest: AssetManifestBuilder, toolkitInfo: ToolkitInfo, reuse?: string[]): Promise> { reuse = reuse || []; const assets = stack.assets; @@ -22,7 +22,7 @@ export async function addMetadataAssetsToManifest(stack: cxapi.CloudFormationSta return {}; } - if (!toolkitInfo) { + if (!toolkitInfo.found) { // eslint-disable-next-line max-len 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)}")`); } diff --git a/packages/aws-cdk/lib/settings.ts b/packages/aws-cdk/lib/settings.ts index f61d2ebd270da..b7f7e04421267 100644 --- a/packages/aws-cdk/lib/settings.ts +++ b/packages/aws-cdk/lib/settings.ts @@ -47,6 +47,22 @@ export type Arguments = { readonly [name: string]: unknown; }; +export interface ConfigurationProps { + /** + * Configuration passed via command line arguments + * + * @default - Nothing passed + */ + readonly commandLineArguments?: Arguments; + + /** + * Whether or not to use context from `.cdk.json` in user home directory + * + * @default true + */ + readonly readUserContext?: boolean; +} + /** * All sources of settings combined */ @@ -66,9 +82,9 @@ export class Configuration { private _projectContext?: Settings; private loaded = false; - constructor(commandLineArguments?: Arguments) { - this.commandLineArguments = commandLineArguments - ? Settings.fromCommandLineArguments(commandLineArguments) + constructor(private readonly props: ConfigurationProps = {}) { + this.commandLineArguments = props.commandLineArguments + ? Settings.fromCommandLineArguments(props.commandLineArguments) : new Settings(); this.commandLineContext = this.commandLineArguments.subSettings([CONTEXT_KEY]).makeReadOnly(); } @@ -95,11 +111,18 @@ export class Configuration { this._projectConfig = await loadAndLog(PROJECT_CONFIG); this._projectContext = await loadAndLog(PROJECT_CONTEXT); - this.context = new Context( + const readUserContext = this.props.readUserContext ?? true; + + const contextSources = [ this.commandLineContext, this.projectConfig.subSettings([CONTEXT_KEY]).makeReadOnly(), this.projectContext, - userConfig.subSettings([CONTEXT_KEY]).makeReadOnly()); + ]; + if (readUserContext) { + contextSources.push(userConfig.subSettings([CONTEXT_KEY]).makeReadOnly()); + } + + this.context = new Context(...contextSources); // Build settings from what's left this.settings = this.defaultConfig diff --git a/packages/aws-cdk/test/api/bootstrap2.test.ts b/packages/aws-cdk/test/api/bootstrap2.test.ts index 59ea35108644e..639f0a6d759bc 100644 --- a/packages/aws-cdk/test/api/bootstrap2.test.ts +++ b/packages/aws-cdk/test/api/bootstrap2.test.ts @@ -4,17 +4,19 @@ jest.mock('../../lib/api/deploy-stack', () => ({ deployStack: mockDeployStack, })); -let mockTheToolkitInfo: any; - import { Bootstrapper, DeployStackOptions, ToolkitInfo } from '../../lib/api'; -import { MockSdkProvider, mockToolkitInfo } from '../util/mock-sdk'; +import { mockBootstrapStack, MockSdk, MockSdkProvider } from '../util/mock-sdk'; let bootstrapper: Bootstrapper; beforeEach(() => { - (ToolkitInfo as any).lookup = jest.fn().mockImplementation(() => Promise.resolve(mockTheToolkitInfo)); bootstrapper = new Bootstrapper({ source: 'default' }); }); +function mockTheToolkitInfo(stackProps: Partial) { + const sdk = new MockSdk(); + (ToolkitInfo as any).lookup = jest.fn().mockResolvedValue(ToolkitInfo.fromStack(mockBootstrapStack(sdk, stackProps), sdk)); +} + describe('Bootstrapping v2', () => { const env = { account: '123456789012', @@ -25,7 +27,8 @@ describe('Bootstrapping v2', () => { let sdk: MockSdkProvider; beforeEach(() => { sdk = new MockSdkProvider({ realSdk: false }); - mockTheToolkitInfo = undefined; + // By default, we'll return a non-found toolkit info + (ToolkitInfo as any).lookup = jest.fn().mockResolvedValue(ToolkitInfo.bootstraplessDeploymentsOnly(sdk.sdk)); }); afterEach(() => { @@ -90,11 +93,14 @@ describe('Bootstrapping v2', () => { }); test('passing trusted accounts without CFN managed policies on the existing stack results in an error', async () => { - mockTheToolkitInfo = { - parameters: { - CloudFormationExecutionPolicies: '', - }, - }; + mockTheToolkitInfo({ + Parameters: [ + { + ParameterKey: 'CloudFormationExecutionPolicies', + ParameterValue: '', + }, + ], + }); await expect(bootstrapper.bootstrapEnvironment(env, sdk, { parameters: { @@ -119,11 +125,14 @@ describe('Bootstrapping v2', () => { test('allow adding trusted account if there was already a policy on the stack', async () => { // GIVEN - mockTheToolkitInfo = { - parameters: { - CloudFormationExecutionPolicies: 'arn:aws:something', - }, - }; + mockTheToolkitInfo({ + Parameters: [ + { + ParameterKey: 'CloudFormationExecutionPolicies', + ParameterValue: 'arn:aws:something', + }, + ], + }); await bootstrapper.bootstrapEnvironment(env, sdk, { parameters: { @@ -135,9 +144,14 @@ describe('Bootstrapping v2', () => { test('Do not allow downgrading bootstrap stack version', async () => { // GIVEN - mockTheToolkitInfo = { - version: 999, - }; + mockTheToolkitInfo({ + Outputs: [ + { + OutputKey: 'BootstrapVersion', + OutputValue: '999', + }, + ], + }); await expect(bootstrapper.bootstrapEnvironment(env, sdk, { parameters: { @@ -200,7 +214,7 @@ describe('Bootstrapping v2', () => { }); test('termination protection is left alone when option is not given', async () => { - mockTheToolkitInfo = mockToolkitInfo({ + mockTheToolkitInfo({ EnableTerminationProtection: true, }); @@ -218,7 +232,7 @@ describe('Bootstrapping v2', () => { }); test('termination protection can be switched off', async () => { - mockTheToolkitInfo = mockToolkitInfo({ + mockTheToolkitInfo({ EnableTerminationProtection: true, }); @@ -275,8 +289,13 @@ describe('Bootstrapping v2', () => { ['AWS_MANAGED_KEY', true, ''], ])('(upgrading) current param %p, createCustomerMasterKey=%p => parameter becomes %p ', async (currentKeyId, createCustomerMasterKey, paramKeyId) => { // GIVEN - mockTheToolkitInfo = mockToolkitInfo({ - Parameters: currentKeyId ? [{ ParameterKey: 'FileAssetsBucketKmsKeyId', ParameterValue: currentKeyId }] : undefined, + mockTheToolkitInfo({ + Parameters: currentKeyId ? [ + { + ParameterKey: 'FileAssetsBucketKmsKeyId', + ParameterValue: currentKeyId, + }, + ] : undefined, }); // WHEN diff --git a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts index 4ffbe14dfddb0..7afea33be6a0b 100644 --- a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts +++ b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts @@ -1,24 +1,39 @@ -const mockToolkitInfoLookup = jest.fn(); jest.mock('../../lib/api/deploy-stack'); -jest.mock('../../lib/api/toolkit-info', () => ({ - ToolkitInfo: { - lookup: mockToolkitInfoLookup, - }, -})); import { CloudFormationDeployments } from '../../lib/api/cloudformation-deployments'; import { deployStack } from '../../lib/api/deploy-stack'; +import { ToolkitInfo } from '../../lib/api/toolkit-info'; import { testStack } from '../util'; -import { MockSdkProvider } from '../util/mock-sdk'; +import { mockBootstrapStack, MockSdkProvider } from '../util/mock-sdk'; let sdkProvider: MockSdkProvider; let deployments: CloudFormationDeployments; +let mockToolkitInfoLookup: jest.Mock; beforeEach(() => { jest.resetAllMocks(); sdkProvider = new MockSdkProvider(); deployments = new CloudFormationDeployments({ sdkProvider }); + ToolkitInfo.lookup = mockToolkitInfoLookup = jest.fn().mockResolvedValue(ToolkitInfo.bootstrapStackNotFoundInfo(sdkProvider.sdk)); }); +function mockSuccessfulBootstrapStackLookup(props?: Record) { + const outputs = { + BucketName: 'BUCKET_NAME', + BucketDomainName: 'BUCKET_ENDPOINT', + BootstrapVersion: '1', + ...props, + }; + + const fakeStack = mockBootstrapStack(sdkProvider.sdk, { + Outputs: Object.entries(outputs).map(([k, v]) => ({ + OutputKey: k, + OutputValue: `${v}`, + })), + }); + + mockToolkitInfoLookup.mockResolvedValue(ToolkitInfo.fromStack(fakeStack, sdkProvider.sdk)); +} + test('placeholders are substituted in CloudFormation execution role', async () => { await deployments.deployStack({ stack: testStack({ @@ -65,8 +80,8 @@ test('deployment fails if bootstrap stack is missing', async () => { }); test('deployment fails if bootstrap stack is too old', async () => { - mockToolkitInfoLookup.mockResolvedValue({ - version: 5, + mockSuccessfulBootstrapStackLookup({ + BootstrapVersion: 5, }); await expect(deployments.deployStack({ @@ -79,3 +94,36 @@ test('deployment fails if bootstrap stack is too old', async () => { }), })).rejects.toThrow(/requires bootstrap stack version '99', found '5'/); }); + +test('if toolkit stack cannot be found but SSM parameter name is present deployment succeeds', async () => { + // FIXME: Mocking a successful bootstrap stack lookup here should not be necessary. + // This should fail and return a placeholder failure object. + mockSuccessfulBootstrapStackLookup({ + BootstrapVersion: 2, + }); + + let requestedParameterName: string; + sdkProvider.stubSSM({ + getParameter(request) { + requestedParameterName = request.Name; + return { + Parameter: { + Value: '99', + }, + }; + }, + }); + + await deployments.deployStack({ + stack: testStack({ + stackName: 'boop', + properties: { + assumeRoleArn: 'bloop:${AWS::Region}:${AWS::AccountId}', + requiresBootstrapStackVersion: 99, + bootstrapStackVersionSsmParameter: '/some/parameter', + }, + }), + }); + + expect(requestedParameterName!).toEqual('/some/parameter'); +}); diff --git a/packages/aws-cdk/test/api/deploy-stack.test.ts b/packages/aws-cdk/test/api/deploy-stack.test.ts index 51c54ec57b886..1908b4716aac0 100644 --- a/packages/aws-cdk/test/api/deploy-stack.test.ts +++ b/packages/aws-cdk/test/api/deploy-stack.test.ts @@ -1,4 +1,4 @@ -import { deployStack } from '../../lib'; +import { deployStack, ToolkitInfo } from '../../lib'; import { DEFAULT_FAKE_TEMPLATE, testStack } from '../util'; import { MockedObject, mockResolvedEnvironment, MockSdk, MockSdkProvider, SyncHandlerSubsetOf } from '../util/mock-sdk'; @@ -57,15 +57,23 @@ beforeEach(() => { updateTerminationProtection: jest.fn((_o) => ({ StackId: 'stack-id' })), }; sdk.stubCloudFormation(cfnMocks as any); + }); -test('do deploy executable change set with 0 changes', async () => { - // WHEN - const ret = await deployStack({ +function standardDeployStackArguments() { + return { stack: FAKE_STACK, - resolvedEnvironment: mockResolvedEnvironment(), sdk, sdkProvider, + resolvedEnvironment: mockResolvedEnvironment(), + toolkitInfo: ToolkitInfo.bootstraplessDeploymentsOnly(sdk), + }; +} + +test('do deploy executable change set with 0 changes', async () => { + // WHEN + const ret = await deployStack({ + ...standardDeployStackArguments(), }); // THEN @@ -76,10 +84,7 @@ test('do deploy executable change set with 0 changes', async () => { test('correctly passes CFN parameters, ignoring ones with empty values', async () => { // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), parameters: { A: 'A-value', B: 'B=value', @@ -108,10 +113,8 @@ test('reuse previous parameters if requested', async () => { // WHEN await deployStack({ + ...standardDeployStackArguments(), stack: FAKE_STACK_WITH_PARAMETERS, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), parameters: { OtherParameter: 'SomeValue', }, @@ -139,10 +142,8 @@ test('do not reuse previous parameters if not requested', async () => { // WHEN await deployStack({ + ...standardDeployStackArguments(), stack: FAKE_STACK_WITH_PARAMETERS, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), parameters: { HasValue: 'SomeValue', OtherParameter: 'SomeValue', @@ -169,10 +170,8 @@ test('throw exception if not enough parameters supplied', async () => { // WHEN await expect(deployStack({ + ...standardDeployStackArguments(), stack: FAKE_STACK_WITH_PARAMETERS, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), parameters: { OtherParameter: 'SomeValue', }, @@ -185,10 +184,7 @@ test('deploy is skipped if template did not change', async () => { // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), }); // THEN @@ -208,10 +204,8 @@ test('deploy is skipped if parameters are the same', async () => { // WHEN await deployStack({ + ...standardDeployStackArguments(), stack: FAKE_STACK_WITH_PARAMETERS, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), parameters: {}, usePreviousParameters: true, }); @@ -233,10 +227,8 @@ test('deploy is not skipped if parameters are different', async () => { // WHEN await deployStack({ + ...standardDeployStackArguments(), stack: FAKE_STACK_WITH_PARAMETERS, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), parameters: { HasValue: 'NewValue', }, @@ -266,10 +258,7 @@ test('if existing stack failed to create, it is deleted and recreated', async () // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), }); // THEN @@ -289,10 +278,7 @@ test('if existing stack failed to create, it is deleted and recreated even if th // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), }); // THEN @@ -308,10 +294,7 @@ test('deploy not skipped if template did not change and --force is applied', asy // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), force: true, }); @@ -330,14 +313,11 @@ test('deploy is skipped if template and tags did not change', async () => { // WHEN await deployStack({ - stack: FAKE_STACK, + ...standardDeployStackArguments(), tags: [ { Key: 'Key1', Value: 'Value1' }, { Key: 'Key2', Value: 'Value2' }, ], - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), }); // THEN @@ -367,6 +347,7 @@ test('deploy not skipped if template did not change but tags changed', async () Value: 'NewValue', }, ], + toolkitInfo: ToolkitInfo.bootstraplessDeploymentsOnly(sdk), }); // THEN @@ -385,10 +366,7 @@ test('deployStack reports no change if describeChangeSet returns specific error' // WHEN const deployResult = await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), }); // THEN @@ -406,10 +384,7 @@ test('deploy not skipped if template did not change but one tag removed', async // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), tags: [ { Key: 'Key1', Value: 'Value1' }, ], @@ -431,10 +406,7 @@ test('deploy is not skipped if stack is in a _FAILED state', async () => { // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), usePreviousParameters: true, }).catch(() => {}); @@ -452,10 +424,7 @@ test('existing stack in UPDATE_ROLLBACK_COMPLETE state can be updated', async () // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), }); // THEN @@ -472,10 +441,7 @@ test('deploy not skipped if template changed', async () => { // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), }); // THEN @@ -485,11 +451,8 @@ test('deploy not skipped if template changed', async () => { test('not executed and no error if --no-execute is given', async () => { // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, + ...standardDeployStackArguments(), execute: false, - resolvedEnvironment: mockResolvedEnvironment(), }); // THEN @@ -499,15 +462,13 @@ test('not executed and no error if --no-execute is given', async () => { test('use S3 url for stack deployment if present in Stack Artifact', async () => { // WHEN await deployStack({ + ...standardDeployStackArguments(), stack: testStack({ stackName: 'withouterrors', properties: { stackTemplateAssetObjectUrl: 'https://use-me-use-me/', }, }), - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), }); // THEN @@ -520,15 +481,13 @@ test('use S3 url for stack deployment if present in Stack Artifact', async () => test('use REST API S3 url with substituted placeholders if manifest url starts with s3://', async () => { // WHEN await deployStack({ + ...standardDeployStackArguments(), stack: testStack({ stackName: 'withouterrors', properties: { stackTemplateAssetObjectUrl: 's3://use-me-use-me-${AWS::AccountId}/object', }, }), - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), }); // THEN @@ -550,11 +509,8 @@ test('changeset is created when stack exists in REVIEW_IN_PROGRESS status', asyn // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, + ...standardDeployStackArguments(), execute: false, - resolvedEnvironment: mockResolvedEnvironment(), }); // THEN @@ -578,11 +534,8 @@ test('changeset is updated when stack exists in CREATE_COMPLETE status', async ( // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, + ...standardDeployStackArguments(), execute: false, - resolvedEnvironment: mockResolvedEnvironment(), }); // THEN @@ -598,10 +551,8 @@ test('changeset is updated when stack exists in CREATE_COMPLETE status', async ( test('deploy with termination protection enabled', async () => { // WHEN await deployStack({ + ...standardDeployStackArguments(), stack: FAKE_STACK_TERMINATION_PROTECTION, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), }); // THEN @@ -613,10 +564,7 @@ test('deploy with termination protection enabled', async () => { test('updateTerminationProtection not called when termination protection is undefined', async () => { // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), }); // THEN @@ -631,10 +579,7 @@ test('updateTerminationProtection called when termination protection is undefine // WHEN await deployStack({ - stack: FAKE_STACK, - sdk, - sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + ...standardDeployStackArguments(), }); // THEN diff --git a/packages/aws-cdk/test/api/toolkit-info.test.ts b/packages/aws-cdk/test/api/toolkit-info.test.ts new file mode 100644 index 0000000000000..113164404325e --- /dev/null +++ b/packages/aws-cdk/test/api/toolkit-info.test.ts @@ -0,0 +1,88 @@ +import { ToolkitInfo } from '../../lib'; +import { errorWithCode, mockBootstrapStack, MockSdk } from '../util/mock-sdk'; + + +let mockSdk: MockSdk; +beforeEach(() => { + mockSdk = new MockSdk(); +}); + +test('failure to read SSM parameter results in upgrade message for existing bootstrap stack under v5', async () => { + // GIVEN + const toolkitInfo = ToolkitInfo.fromStack(mockBootstrapStack(mockSdk, { + Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '4' }], + }), mockSdk); + + mockSdk.stubSsm({ + getParameter() { + throw errorWithCode('AccessDeniedException', 'Computer says no'); + }, + }); + + // THEN + await expect(toolkitInfo.validateVersion(99, '/abc')).rejects.toThrow(/This CDK deployment requires bootstrap stack version/); +}); + +test('failure to read SSM parameter results in exception passthrough for existing bootstrap stack v5 or higher', async () => { + // GIVEN + const toolkitInfo = ToolkitInfo.fromStack(mockBootstrapStack(mockSdk, { + Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '5' }], + }), mockSdk); + + mockSdk.stubSsm({ + getParameter() { + throw errorWithCode('AccessDeniedException', 'Computer says no'); + }, + }); + + // THEN + await expect(toolkitInfo.validateVersion(99, '/abc')).rejects.toThrow(/Computer says no/); +}); + +describe('validateversion without bootstrap stack', () => { + let toolkitInfo: ToolkitInfo; + beforeEach(() => { + toolkitInfo = ToolkitInfo.bootstrapStackNotFoundInfo(mockSdk); + }); + + test('validating version with explicit SSM parameter succeeds', async () => { + // GIVEN + mockSdk.stubSsm({ + getParameter() { + return { Parameter: { Value: '10' } }; + }, + }); + + // THEN + await expect(toolkitInfo.validateVersion(8, '/abc')).resolves.toBeUndefined(); + }); + + test('validating version without explicit SSM parameter fails', async () => { + // WHEN + await expect(toolkitInfo.validateVersion(8, undefined)).rejects.toThrow(/This deployment requires a bootstrap stack with a known name/); + }); + + test('validating version with access denied error gives upgrade hint', async () => { + // GIVEN + mockSdk.stubSsm({ + getParameter() { + throw errorWithCode('AccessDeniedException', 'Computer says no'); + }, + }); + + // WHEN + await expect(toolkitInfo.validateVersion(8, '/abc')).rejects.toThrow(/This CDK deployment requires bootstrap stack version/); + }); + + test('validating version with missing parameter gives bootstrap hint', async () => { + // GIVEN + mockSdk.stubSsm({ + getParameter() { + throw errorWithCode('ParameterNotFound', 'Wut?'); + }, + }); + + // WHEN + await expect(toolkitInfo.validateVersion(8, '/abc')).rejects.toThrow(/Has the environment been bootstrapped?/); + }); +}); \ No newline at end of file diff --git a/packages/aws-cdk/test/assets.test.ts b/packages/aws-cdk/test/assets.test.ts index e3c83009d7c34..2426e20aa0972 100644 --- a/packages/aws-cdk/test/assets.test.ts +++ b/packages/aws-cdk/test/assets.test.ts @@ -3,15 +3,13 @@ import { ToolkitInfo } from '../lib'; import { addMetadataAssetsToManifest } from '../lib/assets'; import { AssetManifestBuilder } from '../lib/util/asset-manifest-builder'; import { testStack } from './util'; +import { MockSdk } from './util/mock-sdk'; +import { MockToolkitInfo } from './util/mock-toolkitinfo'; let toolkit: ToolkitInfo; let assets: AssetManifestBuilder; beforeEach(() => { - toolkit = { - bucketUrl: 'https://bucket', - bucketName: 'bucket', - prepareEcrRepository: jest.fn(), - } as any; + toolkit = new MockToolkitInfo(new MockSdk()); assets = new AssetManifestBuilder(); }); @@ -35,7 +33,7 @@ describe('file assets', () => { // THEN expect(params).toEqual({ - BucketParameter: 'bucket', + BucketParameter: 'MockToolkitBucketName', KeyParameter: 'assets/SomeStackSomeResource4567/||source-hash.js', ArtifactHashParameter: 'source-hash', }); @@ -43,7 +41,7 @@ describe('file assets', () => { expect(assets.toManifest('.').entries).toEqual([ expect.objectContaining({ destination: { - bucketName: 'bucket', + bucketName: 'MockToolkitBucketName', objectKey: 'assets/SomeStackSomeResource4567/source-hash.js', }, source: { @@ -75,7 +73,7 @@ describe('file assets', () => { expect(assets.toManifest('.').entries).toEqual([ expect.objectContaining({ destination: { - bucketName: 'bucket', + bucketName: 'MockToolkitBucketName', objectKey: 'assets/source-hash.js', }, }), diff --git a/packages/aws-cdk/test/context.test.ts b/packages/aws-cdk/test/context.test.ts index 04a5626c2d4b7..c364285044f5e 100644 --- a/packages/aws-cdk/test/context.test.ts +++ b/packages/aws-cdk/test/context.test.ts @@ -32,7 +32,7 @@ test('load context from both files if available', async () => { await fs.writeJSON('cdk.json', { context: { boo: 'far' } }); // WHEN - const config = await new Configuration().load(); + const config = await new Configuration({ readUserContext: false }).load(); // THEN expect(config.context.get('foo')).toBe('bar'); @@ -43,7 +43,7 @@ test('deleted context disappears from new file', async () => { // GIVEN await fs.writeJSON('cdk.context.json', { foo: 'bar' }); await fs.writeJSON('cdk.json', { context: { foo: 'bar' } }); - const config = await new Configuration().load(); + const config = await new Configuration({ readUserContext: false }).load(); // WHEN config.context.unset('foo'); @@ -58,7 +58,7 @@ test('clear deletes from new file', async () => { // GIVEN await fs.writeJSON('cdk.context.json', { foo: 'bar' }); await fs.writeJSON('cdk.json', { context: { boo: 'far' } }); - const config = await new Configuration().load(); + const config = await new Configuration({ readUserContext: false }).load(); // WHEN config.context.clear(); @@ -72,7 +72,7 @@ test('clear deletes from new file', async () => { test('context is preserved in the location from which it is read', async () => { // GIVEN await fs.writeJSON('cdk.json', { context: { 'boo:boo': 'far' } }); - const config = await new Configuration().load(); + const config = await new Configuration({ readUserContext: false }).load(); // WHEN expect(config.context.all).toEqual({ 'boo:boo': 'far' }); @@ -87,7 +87,7 @@ test('surive no context in old file', async () => { // GIVEN await fs.writeJSON('cdk.json', { }); await fs.writeJSON('cdk.context.json', { boo: 'far' }); - const config = await new Configuration().load(); + const config = await new Configuration({ readUserContext: false }).load(); // WHEN expect(config.context.all).toEqual({ boo: 'far' }); @@ -100,7 +100,13 @@ test('surive no context in old file', async () => { test('command line context is merged with stored context', async () => { // GIVEN await fs.writeJSON('cdk.context.json', { boo: 'far' }); - const config = await new Configuration({ context: ['foo=bar'], _: ['command'] } as any).load(); + const config = await new Configuration({ + readUserContext: false, + commandLineArguments: { + context: ['foo=bar'], + _: ['command'], + } as any, + }).load(); // WHEN expect(config.context.all).toEqual({ foo: 'bar', boo: 'far' }); @@ -108,13 +114,13 @@ test('command line context is merged with stored context', async () => { test('can save and load', async () => { // GIVEN - const config1 = await new Configuration().load(); + const config1 = await new Configuration({ readUserContext: false }).load(); config1.context.set('some_key', 'some_value'); await config1.saveContext(); expect(config1.context.get('some_key')).toEqual('some_value'); // WHEN - const config2 = await new Configuration().load(); + const config2 = await new Configuration({ readUserContext: false }).load(); // THEN expect(config2.context.get('some_key')).toEqual('some_value'); @@ -122,13 +128,13 @@ test('can save and load', async () => { test('transient values arent saved to disk', async () => { // GIVEN - const config1 = await new Configuration().load(); + const config1 = await new Configuration({ readUserContext: false }).load(); config1.context.set('some_key', { [TRANSIENT_CONTEXT_KEY]: true, value: 'some_value' }); await config1.saveContext(); expect(config1.context.get('some_key').value).toEqual('some_value'); // WHEN - const config2 = await new Configuration().load(); + const config2 = await new Configuration({ readUserContext: false }).load(); // THEN expect(config2.context.get('some_key')).toEqual(undefined); diff --git a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts index c7cbb1fa862a9..e11e3b48bfcc0 100644 --- a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts @@ -243,3 +243,26 @@ integTest('add tags, left alone on re-bootstrap', withDefaultFixture(async (fixt { Key: 'Foo', Value: 'Bar' }, ]); })); + +integTest('can deploy modern-synthesized stack even if bootstrap stack name is unknown', withDefaultFixture(async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack'); + + await fixture.cdk(['bootstrap', + '--toolkit-stack-name', bootstrapStackName, + '--qualifier', fixture.qualifier, + '--cloudformation-execution-policies', 'arn:aws:iam::aws:policy/AdministratorAccess'], { + modEnv: { + CDK_NEW_BOOTSTRAP: '1', + }, + }); + + // Deploy stack that uses file assets + await fixture.cdkDeploy('lambda', { + options: [ + // Next line explicitly commented to show that we don't pass it! + // '--toolkit-stack-name', bootstrapStackName, + '--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`, + '--context', '@aws-cdk/core:newStyleStackSynthesis=1', + ], + }); +})); \ No newline at end of file diff --git a/packages/aws-cdk/test/util/mock-sdk.ts b/packages/aws-cdk/test/util/mock-sdk.ts index a060cef470a86..9d1f1426a77a6 100644 --- a/packages/aws-cdk/test/util/mock-sdk.ts +++ b/packages/aws-cdk/test/util/mock-sdk.ts @@ -28,7 +28,7 @@ export interface MockSdkProviderOptions { * actually will be called. */ export class MockSdkProvider extends SdkProvider { - private readonly sdk: ISDK; + public readonly sdk: ISDK; constructor(options: MockSdkProviderOptions = {}) { super(FAKE_CREDENTIAL_CHAIN, 'bermuda-triangle-1337', { customUserAgent: 'aws-cdk/jest' }); @@ -84,6 +84,13 @@ export class MockSdkProvider extends SdkProvider { public stubELBv2(stubs: SyncHandlerSubsetOf) { (this.sdk as any).elbv2 = jest.fn().mockReturnValue(partialAwsService(stubs)); } + + /** + * Replace the SSM client with the given object + */ + public stubSSM(stubs: SyncHandlerSubsetOf) { + (this.sdk as any).ssm = jest.fn().mockReturnValue(partialAwsService(stubs)); + } } export class MockSdk implements ISDK { @@ -113,6 +120,13 @@ export class MockSdk implements ISDK { public stubEcr(stubs: SyncHandlerSubsetOf) { this.ecr.mockReturnValue(partialAwsService(stubs)); } + + /** + * Replace the SSM client with the given object + */ + public stubSsm(stubs: SyncHandlerSubsetOf) { + this.ssm.mockReturnValue(partialAwsService(stubs)); + } } /** @@ -197,18 +211,19 @@ export function mockBootstrapStack(sdk: ISDK | undefined, stack?: Partial) { const sdk = new MockSdk(); - return new ToolkitInfo(mockBootstrapStack(sdk, stack), sdk); + return ToolkitInfo.fromStack(mockBootstrapStack(sdk, stack), sdk); } export function mockResolvedEnvironment(): cxapi.Environment { diff --git a/packages/aws-cdk/test/util/mock-toolkitinfo.ts b/packages/aws-cdk/test/util/mock-toolkitinfo.ts new file mode 100644 index 0000000000000..f52ae4cf78267 --- /dev/null +++ b/packages/aws-cdk/test/util/mock-toolkitinfo.ts @@ -0,0 +1,47 @@ +import { ISDK, ToolkitInfo } from '../../lib'; +import { CloudFormationStack } from '../../lib/api/util/cloudformation'; + +export interface MockToolkitInfoProps { + readonly bucketName?: string; + readonly bucketUrl?: string; + readonly version?: number; + readonly bootstrapStack?: CloudFormationStack; +} + +function mockLike any>(): jest.Mock, Parameters> { + return jest.fn(); +} + +export class MockToolkitInfo extends ToolkitInfo { + public readonly found = true; + public readonly bucketUrl: string; + public readonly bucketName: string; + public readonly version: number; + public readonly prepareEcrRepository = mockLike(); + + private readonly _bootstrapStack?: CloudFormationStack; + + constructor(sdk: ISDK, props: MockToolkitInfoProps = {}) { + super(sdk); + + this.bucketName = props.bucketName ?? 'MockToolkitBucketName'; + this.bucketUrl = props.bucketUrl ?? `https://${this.bucketName}.s3.amazonaws.com/`; + this.version = props.version ?? 1; + this._bootstrapStack = props.bootstrapStack; + } + + public get bootstrapStack(): CloudFormationStack { + if (!this._bootstrapStack) { + throw new Error('Bootstrap stack object expected but not supplied to MockToolkitInfo'); + } + return this._bootstrapStack; + } + + public async validateVersion(expectedVersion: number, ssmParameterName: string | undefined): Promise { + const version = ssmParameterName !== undefined ? await this.versionFromSsmParameter(ssmParameterName) : this.version; + + if (expectedVersion > version) { + throw new Error(`This CDK deployment requires bootstrap stack version '${expectedVersion}', found '${version}'. Please run 'cdk bootstrap' with a newer CLI version.`); + } + } +} \ No newline at end of file