From 4a1d31ba1fda18b8ee1ad8a58118cede9c8dfbfb Mon Sep 17 00:00:00 2001 From: Rico Hermans Date: Thu, 7 Sep 2023 12:19:34 +0200 Subject: [PATCH] fix(cli): deployment stops on AccessDenied looking up bootstrap stack (#26925) The CLI always looks up the default bootstrap stack, for backwards compatibility reasons: in case the attributes introduced by the V2 `DefaultStackSynthesizer` that tell it what SSM parameter to use and what bucket to write assets to are not present, it needs to fall back to the default bootstrap stack found in CloudFormation. The code happily survives a `StackNotFound` error, but is not prepared to deal with an `AccessDenied` error, that a customer in #26588 had configured their AWS account for. The essence of the fix here is to catch all errors when looking up the toolkit stack, because they only become relevant if any of the properties of the toolkit stack are ever accessed. The customer also made the point that the lookup didn't even need to happen in the first place, because all information was already there. This is fair, and the organization of the code in this area has been a thorn in my side for a while now. There is some code that doesn't need to be on `ToolkitInfo` (which is the ancient name for the Bootstrap Stack), but is there for legacy reasons. This PR introduces a refactor, where we introduce a new class `EnvironmentResources`, that manages interacting with the bootstrap resources in a particular environment. We can now pass `EnvironmentResources` everywhere we used to pass `ToolkitInfo`, and the actual lookup of the Bootstrap Stack is only triggered if the need arises (which hopefully should be never). Closes #26588. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../lib/api/bootstrap/deploy-bootstrap.ts | 3 +- packages/aws-cdk/lib/api/deploy-stack.ts | 15 +- packages/aws-cdk/lib/api/deployments.ts | 82 +++---- .../aws-cdk/lib/api/environment-resources.ts | 209 ++++++++++++++++++ packages/aws-cdk/lib/api/toolkit-info.ts | 189 +++------------- packages/aws-cdk/lib/assets.ts | 14 +- packages/aws-cdk/lib/cdk-toolkit.ts | 4 +- packages/aws-cdk/lib/cli.ts | 4 +- packages/aws-cdk/lib/import.ts | 14 +- packages/aws-cdk/test/api/bootstrap2.test.ts | 4 +- .../api/cloudformation-deployments.test.ts | 16 +- .../aws-cdk/test/api/deploy-stack.test.ts | 13 +- ....test.ts => environment-resources.test.ts} | 45 +++- packages/aws-cdk/test/assets.test.ts | 156 +++++++------ packages/aws-cdk/test/util/mock-sdk.ts | 2 +- .../aws-cdk/test/util/mock-toolkitinfo.ts | 33 +-- 16 files changed, 460 insertions(+), 343 deletions(-) create mode 100644 packages/aws-cdk/lib/api/environment-resources.ts rename packages/aws-cdk/test/api/{toolkit-info.test.ts => environment-resources.test.ts} (53%) diff --git a/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts b/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts index 406d31fdba5f6..501122697eab5 100644 --- a/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts +++ b/packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts @@ -7,6 +7,7 @@ import { BOOTSTRAP_VERSION_OUTPUT, BootstrapEnvironmentOptions, BOOTSTRAP_VERSIO import * as logging from '../../logging'; import { Mode, SdkProvider, ISDK } from '../aws-auth'; import { deployStack, DeployStackResult } from '../deploy-stack'; +import { NoBootstrapStackEnvironmentResources } from '../environment-resources'; import { DEFAULT_TOOLKIT_STACK_NAME, ToolkitInfo } from '../toolkit-info'; /** @@ -121,7 +122,7 @@ export class BootstrapStack { parameters, usePreviousParameters: options.usePreviousParameters ?? true, // Obviously we can't need a bootstrap stack to deploy a bootstrap stack - toolkitInfo: ToolkitInfo.bootstraplessDeploymentsOnly(this.sdk), + envResources: new NoBootstrapStackEnvironmentResources(this.resolvedEnvironment, this.sdk), }); } } diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index 416b3e4141d17..9ae5ac4e65000 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -4,10 +4,10 @@ import * as chalk from 'chalk'; import * as fs from 'fs-extra'; import * as uuid from 'uuid'; import { ISDK, SdkProvider } from './aws-auth'; +import { EnvironmentResources } from './environment-resources'; import { CfnEvaluationException } from './evaluate-cloudformation-template'; import { HotswapMode, ICON } from './hotswap/common'; import { tryHotswapDeployment } from './hotswap-deployments'; -import { ToolkitInfo } from './toolkit-info'; import { changeSetHasNoChanges, CloudFormationStack, TemplateParameters, waitForChangeSet, waitForStackDeploy, waitForStackDelete, ParameterValues, ParameterChanges, ResourcesToImport, @@ -71,7 +71,7 @@ export interface DeployStackOptions { /** * Information about the bootstrap stack found in the target environment */ - readonly toolkitInfo: ToolkitInfo; + readonly envResources: EnvironmentResources; /** * Role to pass to CloudFormation to execute the change set @@ -262,7 +262,7 @@ export async function deployStack(options: DeployStackOptions): Promise { @@ -582,6 +582,7 @@ async function makeBodyParameter( return { TemplateBody: templateJson }; } + const toolkitInfo = await resources.lookupToolkit(); if (!toolkitInfo.found) { error( `The template for stack "${stack.displayName}" is ${Math.round(templateJson.length / 1024)}KiB. ` + @@ -623,7 +624,7 @@ async function makeBodyParameter( export async function makeBodyParameterAndUpload( stack: cxapi.CloudFormationStackArtifact, resolvedEnvironment: cxapi.Environment, - toolkitInfo: ToolkitInfo, + resources: EnvironmentResources, sdkProvider: SdkProvider, sdk: ISDK, overrideTemplate?: any): Promise { @@ -635,7 +636,7 @@ export async function makeBodyParameterAndUpload( }); const builder = new AssetManifestBuilder(); - const bodyparam = await makeBodyParameter(forceUploadStack, resolvedEnvironment, builder, toolkitInfo, sdk, overrideTemplate); + const bodyparam = await makeBodyParameter(forceUploadStack, resolvedEnvironment, builder, resources, sdk, overrideTemplate); const manifest = builder.toManifest(stack.assembly.directory); await publishAssets(manifest, sdkProvider, resolvedEnvironment, { quiet: true }); return bodyparam; diff --git a/packages/aws-cdk/lib/api/deployments.ts b/packages/aws-cdk/lib/api/deployments.ts index cab9eb922a66d..e6c2254c70ebb 100644 --- a/packages/aws-cdk/lib/api/deployments.ts +++ b/packages/aws-cdk/lib/api/deployments.ts @@ -5,9 +5,9 @@ import { Mode } from './aws-auth/credentials'; import { ISDK } from './aws-auth/sdk'; import { CredentialsOptions, SdkForEnvironment, SdkProvider } from './aws-auth/sdk-provider'; import { deployStack, DeployStackResult, destroyStack, makeBodyParameterAndUpload, DeploymentMethod } from './deploy-stack'; +import { EnvironmentResources, EnvironmentResourcesRegistry } from './environment-resources'; import { HotswapMode } from './hotswap/common'; import { loadCurrentTemplateWithNestedStacks, loadCurrentTemplate, flattenNestedStackNames, TemplateWithNestedStackCount } from './nested-stack-helpers'; -import { ToolkitInfo } from './toolkit-info'; import { CloudFormationStack, Template, ResourcesToImport, ResourceIdentifierSummaries } from './util/cloudformation'; import { StackActivityProgress } from './util/cloudformation/stack-activity-monitor'; import { replaceEnvPlaceholders } from './util/placeholders'; @@ -38,6 +38,11 @@ export interface PreparedSdkWithLookupRoleForEnvironment { * the default credentials (not the assume role credentials) */ readonly didAssumeRole: boolean; + + /** + * An object for accessing the bootstrap resources in this environment + */ + readonly envResources: EnvironmentResources; } export interface DeployStackOptions { @@ -256,6 +261,7 @@ export interface StackExistsOptions { export interface DeploymentsProps { sdkProvider: SdkProvider; + readonly toolkitStackName?: string; readonly quiet?: boolean; } @@ -280,6 +286,11 @@ export interface PreparedSdkForEnvironment { * @default - no execution role is used */ readonly cloudFormationRoleArn?: string; + + /** + * Access class for environmental resources to help the deployment + */ + readonly envResources: EnvironmentResources; } /** @@ -289,12 +300,13 @@ export interface PreparedSdkForEnvironment { */ export class Deployments { private readonly sdkProvider: SdkProvider; - private readonly toolkitInfoCache = new Map(); private readonly sdkCache = new Map(); private readonly publisherCache = new Map(); + private readonly environmentResources: EnvironmentResourcesRegistry; constructor(private readonly props: DeploymentsProps) { this.sdkProvider = props.sdkProvider; + this.environmentResources = new EnvironmentResourcesRegistry(props.toolkitStackName); } public async readCurrentTemplateWithNestedStacks( @@ -317,21 +329,18 @@ export class Deployments { public async resourceIdentifierSummaries( stackArtifact: cxapi.CloudFormationStackArtifact, - toolkitStackName?: string, ): Promise { debug(`Retrieving template summary for stack ${stackArtifact.displayName}.`); // Currently, needs to use `deploy-role` since it may need to read templates in the staging // bucket which have been encrypted with a KMS key (and lookup-role may not read encrypted things) - const { stackSdk, resolvedEnvironment } = await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading); + const { stackSdk, resolvedEnvironment, envResources } = await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading); const cfn = stackSdk.cloudFormation(); - const toolkitInfo = await this.lookupToolkit(resolvedEnvironment, stackSdk, toolkitStackName); - // Upload the template, if necessary, before passing it to CFN const cfnParam = await makeBodyParameterAndUpload( stackArtifact, resolvedEnvironment, - toolkitInfo, + envResources, this.sdkProvider, stackSdk); @@ -355,16 +364,19 @@ export class Deployments { }; } - const { stackSdk, resolvedEnvironment, cloudFormationRoleArn } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); - - const toolkitInfo = await this.lookupToolkit(resolvedEnvironment, stackSdk, options.toolkitStackName); + const { + stackSdk, + resolvedEnvironment, + cloudFormationRoleArn, + envResources, + } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); // Do a verification of the bootstrap stack version await this.validateBootstrapStackVersion( options.stack.stackName, options.stack.requiresBootstrapStackVersion, options.stack.bootstrapStackVersionSsmParameter, - toolkitInfo); + envResources); return deployStack({ stack: options.stack, @@ -376,7 +388,7 @@ export class Deployments { sdkProvider: this.sdkProvider, roleArn: cloudFormationRoleArn, reuseAssets: options.reuseAssets, - toolkitInfo, + envResources, tags: options.tags, deploymentMethod, force: options.force, @@ -420,6 +432,7 @@ export class Deployments { return { resolvedEnvironment: result.resolvedEnvironment, stackSdk: result.sdk, + envResources: result.envResources, }; } } catch { } @@ -464,6 +477,7 @@ export class Deployments { stackSdk: stackSdk.sdk, resolvedEnvironment, cloudFormationRoleArn: arns.cloudFormationRoleArn, + envResources: this.environmentResources.for(resolvedEnvironment, stackSdk.sdk), }; } @@ -504,9 +518,11 @@ export class Deployments { assumeRoleExternalId: stack.lookupRole?.assumeRoleExternalId, }); + const envResources = this.environmentResources.for(resolvedEnvironment, stackSdk.sdk); + // if we succeed in assuming the lookup role, make sure we have the correct bootstrap stack version if (stackSdk.didAssumeRole && stack.lookupRole?.bootstrapStackVersionSsmParameter && stack.lookupRole.requiresBootstrapStackVersion) { - const version = await ToolkitInfo.versionFromSsmParameter(stackSdk.sdk, stack.lookupRole.bootstrapStackVersionSsmParameter); + const version = await envResources.versionFromSsmParameter(stack.lookupRole.bootstrapStackVersionSsmParameter); if (version < stack.lookupRole.requiresBootstrapStackVersion) { throw new Error(`Bootstrap stack version '${stack.lookupRole.requiresBootstrapStackVersion}' is required, found version '${version}'.`); } @@ -515,7 +531,7 @@ export class Deployments { } else if (!stackSdk.didAssumeRole && stack.lookupRole?.requiresBootstrapStackVersion) { warning(upgradeMessage); } - return { ...stackSdk, resolvedEnvironment }; + return { ...stackSdk, resolvedEnvironment, envResources }; } catch (e: any) { debug(e); // only print out the warnings if the lookupRole exists AND there is a required @@ -528,33 +544,18 @@ export class Deployments { } } - /** - * Look up the toolkit for a given environment, using a given SDK - */ - public async lookupToolkit(resolvedEnvironment: cxapi.Environment, sdk: ISDK, toolkitStackName?: string) { - const key = `${resolvedEnvironment.account}:${resolvedEnvironment.region}:${toolkitStackName}`; - const existing = this.toolkitInfoCache.get(key); - if (existing) { - return existing; - } - const ret = await ToolkitInfo.lookup(resolvedEnvironment, sdk, toolkitStackName); - this.toolkitInfoCache.set(key, ret); - return ret; - } - private async prepareAndValidateAssets(asset: cxapi.AssetManifestArtifact, options: AssetOptions) { - const { stackSdk, resolvedEnvironment } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); - const toolkitInfo = await this.lookupToolkit(resolvedEnvironment, stackSdk, options.toolkitStackName); - const stackEnv = await this.sdkProvider.resolveEnvironment(options.stack.environment); + const { envResources } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); + await this.validateBootstrapStackVersion( options.stack.stackName, asset.requiresBootstrapStackVersion, asset.bootstrapStackVersionSsmParameter, - toolkitInfo); + envResources); const manifest = AssetManifest.fromFile(asset.file); - return { manifest, stackEnv }; + return { manifest, stackEnv: envResources.environment }; } /** @@ -582,16 +583,15 @@ export class Deployments { */ // eslint-disable-next-line max-len public async buildSingleAsset(assetArtifact: cxapi.AssetManifestArtifact, assetManifest: AssetManifest, asset: IManifestEntry, options: BuildStackAssetsOptions) { - const { stackSdk, resolvedEnvironment: stackEnv } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); - const toolkitInfo = await this.lookupToolkit(stackEnv, stackSdk, options.toolkitStackName); + const { resolvedEnvironment, envResources } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); await this.validateBootstrapStackVersion( options.stack.stackName, assetArtifact.requiresBootstrapStackVersion, assetArtifact.bootstrapStackVersionSsmParameter, - toolkitInfo); + envResources); - const publisher = this.cachedPublisher(assetManifest, stackEnv, options.stackName); + const publisher = this.cachedPublisher(assetManifest, resolvedEnvironment, options.stackName); await publisher.buildEntry(asset); if (publisher.hasFailures) { throw new Error(`Failed to build asset ${asset.id}`); @@ -624,17 +624,17 @@ export class Deployments { /** * Validate that the bootstrap stack has the right version for this stack + * + * Call into envResources.validateVersion, but prepend the stack name in case of failure. */ private async validateBootstrapStackVersion( stackName: string, requiresBootstrapStackVersion: number | undefined, bootstrapStackVersionSsmParameter: string | undefined, - toolkitInfo: ToolkitInfo) { - - if (requiresBootstrapStackVersion === undefined) { return; } + envResources: EnvironmentResources) { try { - await toolkitInfo.validateVersion(requiresBootstrapStackVersion, bootstrapStackVersionSsmParameter); + await envResources.validateVersion(requiresBootstrapStackVersion, bootstrapStackVersionSsmParameter); } catch (e: any) { throw new Error(`${stackName}: ${e.message}`); } diff --git a/packages/aws-cdk/lib/api/environment-resources.ts b/packages/aws-cdk/lib/api/environment-resources.ts new file mode 100644 index 0000000000000..0b262a13ad919 --- /dev/null +++ b/packages/aws-cdk/lib/api/environment-resources.ts @@ -0,0 +1,209 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { ISDK } from './aws-auth'; +import { EcrRepositoryInfo, ToolkitInfo } from './toolkit-info'; +import { debug, warning } from '../logging'; + +/** + * Registry class for `EnvironmentResources`. + * + * The state management of this class is a bit non-standard. We want to cache + * data related to toolkit stacks and SSM parameters, but we are not in charge + * of ensuring caching of SDKs. Since `EnvironmentResources` needs an SDK to + * function, we treat it as an ephemeral class, and store the actual cached data + * in `EnvironmentResourcesRegistry`. + */ +export class EnvironmentResourcesRegistry { + private readonly cache = new Map(); + + constructor(private readonly toolkitStackName?: string) { + } + + public for(resolvedEnvironment: cxapi.Environment, sdk: ISDK) { + const key = `${resolvedEnvironment.account}:${resolvedEnvironment.region}`; + let envCache = this.cache.get(key); + if (!envCache) { + envCache = emptyCache(); + this.cache.set(key, envCache); + } + return new EnvironmentResources(resolvedEnvironment, sdk, envCache, this.toolkitStackName); + } +} + +/** + * Interface with the account and region we're deploying into + * + * Manages lookups for bootstrapped resources, falling back to the legacy "CDK Toolkit" + * original bootstrap stack if necessary. + * + * The state management of this class is a bit non-standard. We want to cache + * data related to toolkit stacks and SSM parameters, but we are not in charge + * of ensuring caching of SDKs. Since `EnvironmentResources` needs an SDK to + * function, we treat it as an ephemeral class, and store the actual cached data + * in `EnvironmentResourcesRegistry`. + */ +export class EnvironmentResources { + constructor( + public readonly environment: cxapi.Environment, + private readonly sdk: ISDK, + private readonly cache: EnvironmentCache, + private readonly toolkitStackName?: string, + ) {} + + /** + * Look up the toolkit for a given environment, using a given SDK + */ + public async lookupToolkit() { + if (!this.cache.toolkitInfo) { + this.cache.toolkitInfo = await ToolkitInfo.lookup(this.environment, this.sdk, this.toolkitStackName); + } + return this.cache.toolkitInfo; + } + + /** + * 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 | undefined, ssmParameterName: string | undefined) { + if (expectedVersion === undefined) { + // No requirement + return; + } + const defExpectedVersion = expectedVersion; + + if (ssmParameterName !== undefined) { + try { + doValidate(await this.versionFromSsmParameter(ssmParameterName)); + return; + } catch (e: any) { + 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. + const bootstrapStack = await this.lookupToolkit(); + if (bootstrapStack.found && bootstrapStack.version < BOOTSTRAP_TEMPLATE_VERSION_INTRODUCING_GETPARAMETER) { + warning(`Could not read SSM parameter ${ssmParameterName}: ${e.message}, falling back to version from ${bootstrapStack}`); + doValidate(bootstrapStack.version); + return; + } + + throw new Error(`This CDK deployment requires bootstrap stack version '${expectedVersion}', but during the confirmation via SSM parameter ${ssmParameterName} the following error occurred: ${e}`); + } + } + + // No SSM parameter + const bootstrapStack = await this.lookupToolkit(); + doValidate(bootstrapStack.version); + + function doValidate(version: number) { + if (defExpectedVersion > version) { + throw new Error(`This CDK deployment requires bootstrap stack version '${expectedVersion}', found '${version}'. Please run 'cdk bootstrap'.`); + } + } + } + + /** + * Read a version from an SSM parameter, cached + */ + public async versionFromSsmParameter(parameterName: string): Promise { + const existing = this.cache.ssmParameters.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.cache.ssmParameters.set(parameterName, asNumber); + return asNumber; + } catch (e: any) { + 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; + } + } + + public async prepareEcrRepository(repositoryName: string): Promise { + if (!this.sdk) { + throw new Error('ToolkitInfo needs to have been initialized with an sdk to call prepareEcrRepository'); + } + const ecr = this.sdk.ecr(); + + // check if repo already exists + try { + debug(`${repositoryName}: checking if ECR repository already exists`); + const describeResponse = await ecr.describeRepositories({ repositoryNames: [repositoryName] }).promise(); + const existingRepositoryUri = describeResponse.repositories![0]?.repositoryUri; + if (existingRepositoryUri) { + return { repositoryUri: existingRepositoryUri }; + } + } catch (e: any) { + if (e.code !== 'RepositoryNotFoundException') { throw e; } + } + + // create the repo (tag it so it will be easier to garbage collect in the future) + debug(`${repositoryName}: creating ECR repository`); + const assetTag = { Key: 'awscdk:asset', Value: 'true' }; + const response = await ecr.createRepository({ repositoryName, tags: [assetTag] }).promise(); + const repositoryUri = response.repository?.repositoryUri; + if (!repositoryUri) { + throw new Error(`CreateRepository did not return a repository URI for ${repositoryUri}`); + } + + // configure image scanning on push (helps in identifying software vulnerabilities, no additional charge) + debug(`${repositoryName}: enable image scanning`); + await ecr.putImageScanningConfiguration({ repositoryName, imageScanningConfiguration: { scanOnPush: true } }).promise(); + + return { repositoryUri }; + } +} + +export class NoBootstrapStackEnvironmentResources extends EnvironmentResources { + constructor(environment: cxapi.Environment, sdk: ISDK) { + super(environment, sdk, emptyCache()); + } + + /** + * Look up the toolkit for a given environment, using a given SDK + */ + public async lookupToolkit(): Promise { + throw new Error('Trying to perform an operation that requires a bootstrap stack; you should not see this error, this is a bug in the CDK CLI.'); + } +} + +/** + * Data that is cached on a per-environment level + * + * This cache may be shared between different instances of the `EnvironmentResources` class. + */ +interface EnvironmentCache { + readonly ssmParameters: Map; + toolkitInfo?: ToolkitInfo; +} + +function emptyCache(): EnvironmentCache { + return { + ssmParameters: new Map(), + toolkitInfo: undefined, + }; +} + +/** + * The bootstrap template version that introduced ssm:GetParameter + */ +const BOOTSTRAP_TEMPLATE_VERSION_INTRODUCING_GETPARAMETER = 5; diff --git a/packages/aws-cdk/lib/api/toolkit-info.ts b/packages/aws-cdk/lib/api/toolkit-info.ts index f0f66753843ee..6194c188cb627 100644 --- a/packages/aws-cdk/lib/api/toolkit-info.ts +++ b/packages/aws-cdk/lib/api/toolkit-info.ts @@ -3,15 +3,10 @@ import * as chalk from 'chalk'; import { ISDK } from './aws-auth'; import { BOOTSTRAP_VERSION_OUTPUT, BUCKET_DOMAIN_NAME_OUTPUT, BUCKET_NAME_OUTPUT, BOOTSTRAP_VARIANT_PARAMETER, DEFAULT_BOOTSTRAP_VARIANT } from './bootstrap/bootstrap-props'; import { stabilizeStack, CloudFormationStack } from './util/cloudformation'; -import { debug, warning } from '../logging'; +import { debug } from '../logging'; export const DEFAULT_TOOLKIT_STACK_NAME = 'CDKToolkit'; -/** - * 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. * @@ -42,73 +37,49 @@ export abstract class ToolkitInfo { 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, chalk.blue(`cdk bootstrap "${environment.name}"`)); - 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, chalk.blue(`cdk bootstrap "${environment.name}"`)); - return ToolkitInfo.bootstrapStackNotFoundInfo(sdk); - } - - return new ExistingToolkitInfo(stack, sdk); - } + stackName = ToolkitInfo.determineName(stackName); + try { + const stack = await stabilizeStack(cfn, stackName); + 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, chalk.blue(`cdk bootstrap "${environment.name}"`)); + return ToolkitInfo.bootstrapStackNotFoundInfo(stackName); + } + 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, chalk.blue(`cdk bootstrap "${environment.name}"`)); + return ToolkitInfo.bootstrapStackNotFoundInfo(stackName); + } - public static fromStack(stack: CloudFormationStack, sdk: ISDK): ToolkitInfo { - return new ExistingToolkitInfo(stack, sdk); + return new ExistingToolkitInfo(stack); + } catch (e: any) { + return ToolkitInfo.bootstrapStackLookupError(stackName, e); + } } - 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 fromStack(stack: CloudFormationStack): ToolkitInfo { + return new ExistingToolkitInfo(stack); } - 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)'); + public static bootstrapStackNotFoundInfo(stackName: string): ToolkitInfo { + return new BootstrapStackNotFoundInfo(stackName, '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)'); } - /** - * Read a version from an SSM parameter, cached - */ - public static async versionFromSsmParameter(sdk: ISDK, parameterName: string, ssmCache?: Map): Promise { - const existing = ssmCache?.get(parameterName); - if (existing !== undefined) { return existing; } - - const ssm = 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}`); - } - - ssmCache?.set(parameterName, asNumber); - return asNumber; - } catch (e: any) { - 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; - } + public static bootstrapStackLookupError(stackName: string, e: Error): ToolkitInfo { + return new BootstrapStackNotFoundInfo(stackName, `This deployment requires a bootstrap stack with a known name, but during its lookup the following error occurred: ${e}; pass \'--toolkit-stack-name\' or switch to using the \'DefaultStackSynthesizer\' (see https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html)`); } - protected readonly ssmCache = new Map(); public abstract readonly found: boolean; public abstract readonly bucketUrl: string; public abstract readonly bucketName: string; public abstract readonly version: number; public abstract readonly variant: string; public abstract readonly bootstrapStack: CloudFormationStack; + public abstract readonly stackName: string; - constructor(protected readonly sdk: ISDK) { + constructor() { } - public abstract validateVersion(expectedVersion: number, ssmParameterName: string | undefined): Promise; - public abstract prepareEcrRepository(repositoryName: string): Promise; } /** @@ -117,8 +88,8 @@ export abstract class ToolkitInfo { class ExistingToolkitInfo extends ToolkitInfo { public readonly found = true; - constructor(public readonly bootstrapStack: CloudFormationStack, sdk: ISDK) { - super(sdk); + constructor(public readonly bootstrapStack: CloudFormationStack) { + super(); } public get bucketUrl() { @@ -145,83 +116,14 @@ class ExistingToolkitInfo extends ToolkitInfo { 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 ToolkitInfo.versionFromSsmParameter(this.sdk, ssmParameterName, this.ssmCache); - } catch (e: any) { - 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'.`); - } + public get stackName(): string { + return this.bootstrapStack.stackName; } /** * Prepare an ECR repository for uploading to using Docker * */ - public async prepareEcrRepository(repositoryName: string): Promise { - if (!this.sdk) { - throw new Error('ToolkitInfo needs to have been initialized with an sdk to call prepareEcrRepository'); - } - const ecr = this.sdk.ecr(); - - // check if repo already exists - try { - debug(`${repositoryName}: checking if ECR repository already exists`); - const describeResponse = await ecr.describeRepositories({ repositoryNames: [repositoryName] }).promise(); - const existingRepositoryUri = describeResponse.repositories![0]?.repositoryUri; - if (existingRepositoryUri) { - return { repositoryUri: existingRepositoryUri }; - } - } catch (e: any) { - if (e.code !== 'RepositoryNotFoundException') { throw e; } - } - - // create the repo (tag it so it will be easier to garbage collect in the future) - debug(`${repositoryName}: creating ECR repository`); - const assetTag = { Key: 'awscdk:asset', Value: 'true' }; - const response = await ecr.createRepository({ repositoryName, tags: [assetTag] }).promise(); - const repositoryUri = response.repository?.repositoryUri; - if (!repositoryUri) { - throw new Error(`CreateRepository did not return a repository URI for ${repositoryUri}`); - } - - // configure image scanning on push (helps in identifying software vulnerabilities, no additional charge) - debug(`${repositoryName}: enable image scanning`); - await ecr.putImageScanningConfiguration({ repositoryName, imageScanningConfiguration: { scanOnPush: true } }).promise(); - - return { repositoryUri }; - } - private requireOutput(output: string): string { 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.`); @@ -243,8 +145,8 @@ class ExistingToolkitInfo extends ToolkitInfo { class BootstrapStackNotFoundInfo extends ToolkitInfo { public readonly found = false; - constructor(sdk: ISDK, private readonly errorMessage: string) { - super(sdk); + constructor(public readonly stackName: string, private readonly errorMessage: string) { + super(); } public get bootstrapStack(): CloudFormationStack { @@ -267,31 +169,6 @@ class BootstrapStackNotFoundInfo extends ToolkitInfo { 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 ToolkitInfo.versionFromSsmParameter(this.sdk, ssmParameterName, this.ssmCache); - } catch (e: any) { - 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); } diff --git a/packages/aws-cdk/lib/assets.ts b/packages/aws-cdk/lib/assets.ts index ee0db6771fba9..317e8c3f34272 100644 --- a/packages/aws-cdk/lib/assets.ts +++ b/packages/aws-cdk/lib/assets.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; +import { EnvironmentResources } from './api/environment-resources'; import { ToolkitInfo } from './api/toolkit-info'; import { debug } from './logging'; import { AssetManifestBuilder } from './util/asset-manifest-builder'; @@ -14,7 +15,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, envResources: EnvironmentResources, reuse?: string[]): Promise> { reuse = reuse || []; const assets = stack.assets; @@ -22,6 +23,7 @@ export async function addMetadataAssetsToManifest(stack: cxapi.CloudFormationSta return {}; } + const toolkitInfo = await envResources.lookupToolkit(); 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 "${chalk.blue('cdk bootstrap ' + stack.environment!.name)}")`); @@ -44,14 +46,14 @@ export async function addMetadataAssetsToManifest(stack: cxapi.CloudFormationSta throw new Error('Unexpected: stack assembly is required in order to find assets in assembly directory'); } - Object.assign(params, await prepareAsset(asset, assetManifest, toolkitInfo)); + Object.assign(params, await prepareAsset(asset, assetManifest, envResources, toolkitInfo)); } return params; } // eslint-disable-next-line max-len -async function prepareAsset(asset: cxschema.AssetMetadataEntry, assetManifest: AssetManifestBuilder, toolkitInfo: ToolkitInfo): Promise> { +async function prepareAsset(asset: cxschema.AssetMetadataEntry, assetManifest: AssetManifestBuilder, envResources: EnvironmentResources, toolkitInfo: ToolkitInfo): Promise> { switch (asset.packaging) { case 'zip': case 'file': @@ -61,7 +63,7 @@ async function prepareAsset(asset: cxschema.AssetMetadataEntry, assetManifest: A toolkitInfo, asset.packaging === 'zip' ? cxschema.FileAssetPackaging.ZIP_DIRECTORY : cxschema.FileAssetPackaging.FILE); case 'container-image': - return prepareDockerImageAsset(asset, assetManifest, toolkitInfo); + return prepareDockerImageAsset(asset, assetManifest, envResources); default: // eslint-disable-next-line max-len throw new Error(`Unsupported packaging type: ${(asset as any).packaging}. You might need to upgrade your aws-cdk toolkit to support this asset type.`); @@ -101,7 +103,7 @@ function prepareFileAsset( async function prepareDockerImageAsset( asset: cxschema.ContainerImageAssetMetadataEntry, assetManifest: AssetManifestBuilder, - toolkitInfo: ToolkitInfo): Promise> { + envResources: EnvironmentResources): Promise> { // Pre-1.21.0, repositoryName can be specified by the user or can be left out, in which case we make // a per-asset repository which will get adopted and cleaned up along with the stack. @@ -114,7 +116,7 @@ async function prepareDockerImageAsset( const repositoryName = asset.repositoryName ?? 'cdk/' + asset.id.replace(/[:/]/g, '-').toLowerCase(); // Make sure the repository exists, since the 'cdk-assets' tool will not create it for us. - const { repositoryUri } = await toolkitInfo.prepareEcrRepository(repositoryName); + const { repositoryUri } = await envResources.prepareEcrRepository(repositoryName); const imageTag = asset.imageTag ?? asset.sourceHash; assetManifest.addDockerImageAsset(asset.sourceHash, { diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index a34c3d78e9cd0..d85cad9a90214 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -493,9 +493,7 @@ export class CdkToolkit { highlight(stack.displayName); - const resourceImporter = new ResourceImporter(stack, this.props.deployments, { - toolkitStackName: options.toolkitStackName, - }); + const resourceImporter = new ResourceImporter(stack, this.props.deployments); const { additions, hasNonAdditions } = await resourceImporter.discoverImportableResources(options.force); if (additions.length === 0) { warning('%s: no new resources compared to the currently deployed stack, skipping import.', chalk.bold(stack.displayName)); diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index 6a00e74105efb..70d4184b09de2 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -356,8 +356,6 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise { const ret: ResourceIdentifiers = {}; - const resourceIdentifierSummaries = await this.cfn.resourceIdentifierSummaries(this.stack, this.options.toolkitStackName); + const resourceIdentifierSummaries = await this.cfn.resourceIdentifierSummaries(this.stack); for (const summary of resourceIdentifierSummaries) { if ('ResourceType' in summary && summary.ResourceType && 'ResourceIdentifiers' in summary && summary.ResourceIdentifiers) { ret[summary.ResourceType] = (summary.ResourceIdentifiers ?? [])?.map(x => x.split(',')); diff --git a/packages/aws-cdk/test/api/bootstrap2.test.ts b/packages/aws-cdk/test/api/bootstrap2.test.ts index 427ea1daf0906..d9ec9d563768a 100644 --- a/packages/aws-cdk/test/api/bootstrap2.test.ts +++ b/packages/aws-cdk/test/api/bootstrap2.test.ts @@ -26,7 +26,7 @@ afterEach(() => { function mockTheToolkitInfo(stackProps: Partial) { const sdk = new MockSdk(); - (ToolkitInfo as any).lookup = jest.fn().mockResolvedValue(ToolkitInfo.fromStack(mockBootstrapStack(sdk, stackProps), sdk)); + (ToolkitInfo as any).lookup = jest.fn().mockResolvedValue(ToolkitInfo.fromStack(mockBootstrapStack(sdk, stackProps))); } describe('Bootstrapping v2', () => { @@ -40,7 +40,7 @@ describe('Bootstrapping v2', () => { beforeEach(() => { sdk = new MockSdkProvider({ realSdk: false }); // By default, we'll return a non-found toolkit info - (ToolkitInfo as any).lookup = jest.fn().mockResolvedValue(ToolkitInfo.bootstraplessDeploymentsOnly(sdk.sdk)); + (ToolkitInfo as any).lookup = jest.fn().mockResolvedValue(ToolkitInfo.bootstrapStackNotFoundInfo('BootstrapStack')); const value = { Policy: { PolicyName: 'my-policy', diff --git a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts index fdb4b8a5eb0e5..ac1fed6176e1b 100644 --- a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts +++ b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { }, }); - ToolkitInfo.lookup = mockToolkitInfoLookup = jest.fn().mockResolvedValue(ToolkitInfo.bootstrapStackNotFoundInfo(sdkProvider.sdk)); + ToolkitInfo.lookup = mockToolkitInfoLookup = jest.fn().mockResolvedValue(ToolkitInfo.bootstrapStackNotFoundInfo('TestBootstrapStack')); }); function mockSuccessfulBootstrapStackLookup(props?: Record) { @@ -55,7 +55,7 @@ function mockSuccessfulBootstrapStackLookup(props?: Record) { })), }); - mockToolkitInfoLookup.mockResolvedValue(ToolkitInfo.fromStack(fakeStack, sdkProvider.sdk)); + mockToolkitInfoLookup.mockResolvedValue(ToolkitInfo.fromStack(fakeStack)); } test('passes through hotswap=true to deployStack()', async () => { @@ -134,12 +134,12 @@ 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, - }); +test.each([false, true])('if toolkit stack be found: %p but SSM parameter name is present deployment succeeds', async (canLookup) => { + if (canLookup) { + mockSuccessfulBootstrapStackLookup({ + BootstrapVersion: 2, + }); + } let requestedParameterName: string; sdkProvider.stubSSM({ diff --git a/packages/aws-cdk/test/api/deploy-stack.test.ts b/packages/aws-cdk/test/api/deploy-stack.test.ts index e3bda40dfde2c..440d95cbff8b2 100644 --- a/packages/aws-cdk/test/api/deploy-stack.test.ts +++ b/packages/aws-cdk/test/api/deploy-stack.test.ts @@ -1,10 +1,11 @@ /* eslint-disable import/order */ -import { deployStack, DeployStackOptions, ToolkitInfo } from '../../lib/api'; +import { deployStack, DeployStackOptions } from '../../lib/api'; import { HotswapMode } from '../../lib/api/hotswap/common'; import { tryHotswapDeployment } from '../../lib/api/hotswap-deployments'; import { setCI } from '../../lib/logging'; import { DEFAULT_FAKE_TEMPLATE, testStack } from '../util'; import { MockedObject, mockResolvedEnvironment, MockSdk, MockSdkProvider, SyncHandlerSubsetOf } from '../util/mock-sdk'; +import { NoBootstrapStackEnvironmentResources } from '../../lib/api/environment-resources'; jest.mock('../../lib/api/hotswap-deployments'); @@ -76,12 +77,13 @@ beforeEach(() => { }); function standardDeployStackArguments(): DeployStackOptions { + const resolvedEnvironment = mockResolvedEnvironment(); return { stack: FAKE_STACK, sdk, sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), - toolkitInfo: ToolkitInfo.bootstraplessDeploymentsOnly(sdk), + resolvedEnvironment, + envResources: new NoBootstrapStackEnvironmentResources(resolvedEnvironment, sdk), }; } @@ -530,18 +532,19 @@ test('deploy not skipped if template did not change but tags changed', async () }); // WHEN + const resolvedEnvironment = mockResolvedEnvironment(); await deployStack({ stack: FAKE_STACK, sdk, sdkProvider, - resolvedEnvironment: mockResolvedEnvironment(), + resolvedEnvironment, tags: [ { Key: 'Key', Value: 'NewValue', }, ], - toolkitInfo: ToolkitInfo.bootstraplessDeploymentsOnly(sdk), + envResources: new NoBootstrapStackEnvironmentResources(resolvedEnvironment, sdk), }); // THEN diff --git a/packages/aws-cdk/test/api/toolkit-info.test.ts b/packages/aws-cdk/test/api/environment-resources.test.ts similarity index 53% rename from packages/aws-cdk/test/api/toolkit-info.test.ts rename to packages/aws-cdk/test/api/environment-resources.test.ts index 0c74db1763477..1d4d1b67352bd 100644 --- a/packages/aws-cdk/test/api/toolkit-info.test.ts +++ b/packages/aws-cdk/test/api/environment-resources.test.ts @@ -1,17 +1,39 @@ /* eslint-disable import/order */ import { ToolkitInfo } from '../../lib/api'; +import { EnvironmentResourcesRegistry } from '../../lib/api/environment-resources'; import { errorWithCode, mockBootstrapStack, MockSdk } from '../util/mock-sdk'; +import { MockToolkitInfo } from '../util/mock-toolkitinfo'; let mockSdk: MockSdk; +let envRegistry: EnvironmentResourcesRegistry; +let toolkitMock: ReturnType; beforeEach(() => { mockSdk = new MockSdk(); + envRegistry = new EnvironmentResourcesRegistry(); + toolkitMock = MockToolkitInfo.setup(); }); +afterEach(() => { + toolkitMock.dispose(); +}); + +function mockToolkitInfo(ti: ToolkitInfo) { + ToolkitInfo.lookup = jest.fn().mockResolvedValue(ti); +} + +function envResources() { + return envRegistry.for({ + account: '11111111', + region: 'us-nowhere', + name: 'aws://11111111/us-nowhere', + }, 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, { + mockToolkitInfo(ToolkitInfo.fromStack(mockBootstrapStack(mockSdk, { Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '4' }], - }), mockSdk); + }))); mockSdk.stubSsm({ getParameter() { @@ -20,14 +42,14 @@ test('failure to read SSM parameter results in upgrade message for existing boot }); // THEN - await expect(toolkitInfo.validateVersion(99, '/abc')).rejects.toThrow(/This CDK deployment requires bootstrap stack version/); + await expect(envResources().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, { + mockToolkitInfo(ToolkitInfo.fromStack(mockBootstrapStack(mockSdk, { Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '5' }], - }), mockSdk); + }))); mockSdk.stubSsm({ getParameter() { @@ -36,13 +58,12 @@ test('failure to read SSM parameter results in exception passthrough for existin }); // THEN - await expect(toolkitInfo.validateVersion(99, '/abc')).rejects.toThrow(/Computer says no/); + await expect(envResources().validateVersion(99, '/abc')).rejects.toThrow(/Computer says no/); }); describe('validateversion without bootstrap stack', () => { - let toolkitInfo: ToolkitInfo; beforeEach(() => { - toolkitInfo = ToolkitInfo.bootstrapStackNotFoundInfo(mockSdk); + mockToolkitInfo(ToolkitInfo.bootstrapStackNotFoundInfo('TestBootstrapStack')); }); test('validating version with explicit SSM parameter succeeds', async () => { @@ -54,12 +75,12 @@ describe('validateversion without bootstrap stack', () => { }); // THEN - await expect(toolkitInfo.validateVersion(8, '/abc')).resolves.toBeUndefined(); + await expect(envResources().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/); + await expect(envResources().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 () => { @@ -71,7 +92,7 @@ describe('validateversion without bootstrap stack', () => { }); // WHEN - await expect(toolkitInfo.validateVersion(8, '/abc')).rejects.toThrow(/This CDK deployment requires bootstrap stack version/); + await expect(envResources().validateVersion(8, '/abc')).rejects.toThrow(/This CDK deployment requires bootstrap stack version/); }); test('validating version with missing parameter gives bootstrap hint', async () => { @@ -83,6 +104,6 @@ describe('validateversion without bootstrap stack', () => { }); // WHEN - await expect(toolkitInfo.validateVersion(8, '/abc')).rejects.toThrow(/Has the environment been bootstrapped?/); + await expect(envResources().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 e71942324f2f8..b74a7bca6751a 100644 --- a/packages/aws-cdk/test/assets.test.ts +++ b/packages/aws-cdk/test/assets.test.ts @@ -1,17 +1,27 @@ /* eslint-disable import/order */ import { AssetMetadataEntry } from '@aws-cdk/cloud-assembly-schema'; -import { testStack } from './util'; -import { MockSdk } from './util/mock-sdk'; -import { MockToolkitInfo } from './util/mock-toolkitinfo'; -import { ToolkitInfo } from '../lib/api'; +import { testStack, withMocked } from './util'; import { addMetadataAssetsToManifest } from '../lib/assets'; import { AssetManifestBuilder } from '../lib/util/asset-manifest-builder'; +import { EnvironmentResources, EnvironmentResourcesRegistry } from '../lib/api/environment-resources'; +import { MockSdk } from './util/mock-sdk'; +import { MockToolkitInfo } from './util/mock-toolkitinfo'; -let toolkit: ToolkitInfo; let assets: AssetManifestBuilder; +let envRegistry: EnvironmentResourcesRegistry; +let envResources: EnvironmentResources; +let toolkitMock: ReturnType; beforeEach(() => { - toolkit = new MockToolkitInfo(new MockSdk()); assets = new AssetManifestBuilder(); + envRegistry = new EnvironmentResourcesRegistry(); + + const sdk = new MockSdk(); + envResources = envRegistry.for({ account: '11111111', region: 'us-nowhere', name: 'aws://11111111/us-nowhere' }, sdk); + toolkitMock = MockToolkitInfo.setup(); +}); + +afterEach(() => { + toolkitMock.dispose(); }); describe('file assets', () => { @@ -31,7 +41,7 @@ describe('file assets', () => { ]); // WHEN - const params = await addMetadataAssetsToManifest(stack, assets, toolkit); + const params = await addMetadataAssetsToManifest(stack, assets, envResources); // THEN expect(params).toEqual({ @@ -70,7 +80,7 @@ describe('file assets', () => { ]); // WHEN - await addMetadataAssetsToManifest(stack, assets, toolkit); + await addMetadataAssetsToManifest(stack, assets, envResources); // THEN expect(assets.toManifest('.').entries).toEqual([ @@ -98,7 +108,7 @@ describe('file assets', () => { ]); // WHEN - const params = await addMetadataAssetsToManifest(stack, assets, toolkit, ['SomeStackSomeResource4567']); + const params = await addMetadataAssetsToManifest(stack, assets, envResources, ['SomeStackSomeResource4567']); // THEN expect(params).toEqual({ @@ -110,38 +120,40 @@ describe('file assets', () => { describe('docker assets', () => { test('parameter and no repository name (old)', async () => { - // GIVEN - const stack = stackWithAssets([ - { - id: 'Stack:Construct/ABC123', - imageNameParameter: 'MyParameter', - packaging: 'container-image', - path: '/foo', - sourceHash: '0123456789abcdef', - }, - ]); - mockFn(toolkit.prepareEcrRepository).mockResolvedValue({ repositoryUri: 'docker.uri' }); - - // WHEN - const params = await addMetadataAssetsToManifest(stack, assets, toolkit); - - // THEN - expect(toolkit.prepareEcrRepository).toHaveBeenCalledWith('cdk/stack-construct-abc123'); - expect(params).toEqual({ - MyParameter: 'docker.uri:0123456789abcdef', - }); - expect(assets.toManifest('.').entries).toEqual([ - expect.objectContaining({ - type: 'docker-image', - destination: { - imageTag: '0123456789abcdef', - repositoryName: 'cdk/stack-construct-abc123', - }, - source: { - directory: '/foo', + await withMocked(envResources, 'prepareEcrRepository', async () => { + // GIVEN + const stack = stackWithAssets([ + { + id: 'Stack:Construct/ABC123', + imageNameParameter: 'MyParameter', + packaging: 'container-image', + path: '/foo', + sourceHash: '0123456789abcdef', }, - }), - ]); + ]); + mockFn(envResources.prepareEcrRepository).mockResolvedValue({ repositoryUri: 'docker.uri' }); + + // WHEN + const params = await addMetadataAssetsToManifest(stack, assets, envResources); + + // THEN + expect(envResources.prepareEcrRepository).toHaveBeenCalledWith('cdk/stack-construct-abc123'); + expect(params).toEqual({ + MyParameter: 'docker.uri:0123456789abcdef', + }); + expect(assets.toManifest('.').entries).toEqual([ + expect.objectContaining({ + type: 'docker-image', + destination: { + imageTag: '0123456789abcdef', + repositoryName: 'cdk/stack-construct-abc123', + }, + source: { + directory: '/foo', + }, + }), + ]); + }); }); test('if parameter is left out then repo and tag are required', async () => { @@ -155,41 +167,43 @@ describe('docker assets', () => { }, ]); - await expect(addMetadataAssetsToManifest(stack, assets, toolkit)).rejects.toThrow('Invalid Docker image asset'); + await expect(addMetadataAssetsToManifest(stack, assets, envResources)).rejects.toThrow('Invalid Docker image asset'); }); test('no parameter and repo/tag name (new)', async () => { - // GIVEN - const stack = stackWithAssets([ - { - id: 'Stack:Construct/ABC123', - repositoryName: 'reponame', - imageTag: '12345', - packaging: 'container-image', - path: '/foo', - sourceHash: '0123456789abcdef', - }, - ]); - mockFn(toolkit.prepareEcrRepository).mockResolvedValue({ repositoryUri: 'docker.uri' }); - - // WHEN - const params = await addMetadataAssetsToManifest(stack, assets, toolkit); - - // THEN - expect(toolkit.prepareEcrRepository).toHaveBeenCalledWith('reponame'); - expect(params).toEqual({}); // No parameters! - expect(assets.toManifest('.').entries).toEqual([ - expect.objectContaining({ - type: 'docker-image', - destination: { - imageTag: '12345', + await withMocked(envResources, 'prepareEcrRepository', async () => { + // GIVEN + const stack = stackWithAssets([ + { + id: 'Stack:Construct/ABC123', repositoryName: 'reponame', + imageTag: '12345', + packaging: 'container-image', + path: '/foo', + sourceHash: '0123456789abcdef', }, - source: { - directory: '/foo', - }, - }), - ]); + ]); + mockFn(envResources.prepareEcrRepository).mockResolvedValue({ repositoryUri: 'docker.uri' }); + + // WHEN + const params = await addMetadataAssetsToManifest(stack, assets, envResources); + + // THEN + expect(envResources.prepareEcrRepository).toHaveBeenCalledWith('reponame'); + expect(params).toEqual({}); // No parameters! + expect(assets.toManifest('.').entries).toEqual([ + expect.objectContaining({ + type: 'docker-image', + destination: { + imageTag: '12345', + repositoryName: 'reponame', + }, + source: { + directory: '/foo', + }, + }), + ]); + }); }); test('reuse', async () => { @@ -205,7 +219,7 @@ describe('docker assets', () => { ]); // WHEN - const params = await addMetadataAssetsToManifest(stack, assets, toolkit, ['SomeStackSomeResource4567']); + const params = await addMetadataAssetsToManifest(stack, assets, envResources, ['SomeStackSomeResource4567']); // THEN expect(params).toEqual({ diff --git a/packages/aws-cdk/test/util/mock-sdk.ts b/packages/aws-cdk/test/util/mock-sdk.ts index 6ce37d295a0f8..0d943fadb3dea 100644 --- a/packages/aws-cdk/test/util/mock-sdk.ts +++ b/packages/aws-cdk/test/util/mock-sdk.ts @@ -306,7 +306,7 @@ export function mockBootstrapStack(sdk: ISDK | undefined, stack?: Partial) { const sdk = new MockSdk(); - return ToolkitInfo.fromStack(mockBootstrapStack(sdk, stack), sdk); + return ToolkitInfo.fromStack(mockBootstrapStack(sdk, stack)); } 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 index b848304148a08..0f4de75fcddbe 100644 --- a/packages/aws-cdk/test/util/mock-toolkitinfo.ts +++ b/packages/aws-cdk/test/util/mock-toolkitinfo.ts @@ -1,5 +1,5 @@ /* eslint-disable import/order */ -import { ISDK, ToolkitInfo, DEFAULT_BOOTSTRAP_VARIANT } from '../../lib/api'; +import { ToolkitInfo, DEFAULT_BOOTSTRAP_VARIANT } from '../../lib/api'; import { CloudFormationStack } from '../../lib/api/util/cloudformation'; export interface MockToolkitInfoProps { @@ -9,22 +9,31 @@ export interface MockToolkitInfoProps { readonly bootstrapStack?: CloudFormationStack; } -function mockLike any>(): jest.Mock, Parameters> { - return jest.fn(); -} - export class MockToolkitInfo extends ToolkitInfo { + public static setup(toolkitInfo?: ToolkitInfo) { + toolkitInfo = toolkitInfo ?? new MockToolkitInfo(); + const orig = ToolkitInfo.lookup; + ToolkitInfo.lookup = jest.fn().mockResolvedValue(toolkitInfo); + + return { + toolkitInfo, + dispose: () => { + ToolkitInfo.lookup = orig; + }, + }; + } + public readonly found = true; public readonly bucketUrl: string; public readonly bucketName: string; public readonly version: number; public readonly variant: string; - public readonly prepareEcrRepository = mockLike(); + public readonly stackName = 'MockBootstrapStack'; private readonly _bootstrapStack?: CloudFormationStack; - constructor(sdk: ISDK, props: MockToolkitInfoProps = {}) { - super(sdk); + constructor(props: MockToolkitInfoProps = {}) { + super(); this.bucketName = props.bucketName ?? 'MockToolkitBucketName'; this.bucketUrl = props.bucketUrl ?? `https://${this.bucketName}.s3.amazonaws.com/`; @@ -39,12 +48,4 @@ export class MockToolkitInfo extends ToolkitInfo { } return this._bootstrapStack; } - - public async validateVersion(expectedVersion: number, ssmParameterName: string | undefined): Promise { - const version = ssmParameterName !== undefined ? await ToolkitInfo.versionFromSsmParameter(this.sdk, 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.`); - } - } }