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.`); - } - } }