diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/util/directories.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/util/directories.ts index 501322867..7ae5b2b9b 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/util/directories.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/util/directories.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { ToolkitError } from '../api/toolkit-error'; /** * Return a location that will be used as the CDK home directory. @@ -34,3 +35,30 @@ export function cdkHomeDir() { export function cdkCacheDir() { return path.join(cdkHomeDir(), 'cache'); } + +/** + * From the start location, find the directory that contains the bundled package's package.json + * + * You must assume the caller of this function will be bundled and the package root dir + * is not going to be the same as the package the caller currently lives in. + */ +export function bundledPackageRootDir(start: string): string; +export function bundledPackageRootDir(start: string, fail: true): string; +export function bundledPackageRootDir(start: string, fail: false): string | undefined; +export function bundledPackageRootDir(start: string, fail?: boolean) { + function _rootDir(dirname: string): string | undefined { + const manifestPath = path.join(dirname, 'package.json'); + if (fs.existsSync(manifestPath)) { + return dirname; + } + if (path.dirname(dirname) === dirname) { + if (fail ?? true) { + throw new ToolkitError('Unable to find package manifest'); + } + return undefined; + } + return _rootDir(path.dirname(dirname)); + } + + return _rootDir(start); +} diff --git a/packages/@aws-cdk/toolkit-lib/CODE_REGISTRY.md b/packages/@aws-cdk/toolkit-lib/CODE_REGISTRY.md index bf9d4daec..0ec17165e 100644 --- a/packages/@aws-cdk/toolkit-lib/CODE_REGISTRY.md +++ b/packages/@aws-cdk/toolkit-lib/CODE_REGISTRY.md @@ -26,6 +26,9 @@ | CDK_TOOLKIT_I7010 | Confirm destroy stacks | info | n/a | | CDK_TOOLKIT_E7010 | Action was aborted due to negative confirmation of request | error | n/a | | CDK_TOOLKIT_E7900 | Stack deletion failed | error | n/a | +| CDK_TOOLKIT_I9000 | Provides bootstrap times | info | n/a | +| CDK_TOOLKIT_I9900 | Bootstrap results on success | info | n/a | +| CDK_TOOLKIT_E9900 | Bootstrap failed | error | n/a | | CDK_ASSEMBLY_I0042 | Writing updated context | debug | n/a | | CDK_ASSEMBLY_I0241 | Fetching missing context | debug | n/a | | CDK_ASSEMBLY_I1000 | Cloud assembly output starts | debug | n/a | diff --git a/packages/@aws-cdk/toolkit-lib/build-tools/bundle.mjs b/packages/@aws-cdk/toolkit-lib/build-tools/bundle.mjs index 6c4e0b050..745ef8602 100644 --- a/packages/@aws-cdk/toolkit-lib/build-tools/bundle.mjs +++ b/packages/@aws-cdk/toolkit-lib/build-tools/bundle.mjs @@ -16,11 +16,11 @@ await Promise.all([ copyFromCli(['build-info.json']), copyFromCli(['/db.json.gz']), copyFromCli(['lib', 'index_bg.wasm']), + copyFromCli(['lib', 'api', 'bootstrap', 'bootstrap-template.yaml']), ]); // # Copy all resources that aws_cdk/generate.sh produced, and some othersCall the generator for the // cp -R $aws_cdk/lib/init-templates ./lib/ -// mkdir -p ./lib/api/bootstrap/ && cp $aws_cdk/lib/api/bootstrap/bootstrap-template.yaml ./lib/api/bootstrap/ await esbuild.build({ outdir: 'lib', diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/index.ts new file mode 100644 index 000000000..6c4915128 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/index.ts @@ -0,0 +1,229 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { environmentsFromDescriptors } from './private'; +import type { Tag } from '../../api/aws-cdk'; +import type { ICloudAssemblySource } from '../../api/cloud-assembly'; +import { ALL_STACKS } from '../../api/cloud-assembly/private'; +import { assemblyFromSource } from '../../toolkit/private'; + +/** + * Create manage bootstrap environments + */ +export class BootstrapEnvironments { + /** + * Create from a list of environment descriptors + * List of strings like `['aws://012345678912/us-east-1', 'aws://234567890123/eu-west-1']` + */ + static fromList(environments: string[]): BootstrapEnvironments { + return new BootstrapEnvironments(environmentsFromDescriptors(environments)); + } + + /** + * Create from a cloud assembly source + */ + static fromCloudAssemblySource(cx: ICloudAssemblySource): BootstrapEnvironments { + return new BootstrapEnvironments(async () => { + const assembly = await assemblyFromSource(cx); + const stackCollection = assembly.selectStacksV2(ALL_STACKS); + return stackCollection.stackArtifacts.map(stack => stack.environment); + }); + } + + private constructor(private readonly envProvider: cxapi.Environment[] | (() => Promise)) { + } + + async getEnvironments(): Promise { + if (Array.isArray(this.envProvider)) { + return this.envProvider; + } + return this.envProvider(); + } +} + +/** + * Options for Bootstrap + */ +export interface BootstrapOptions { + + /** + * Bootstrap environment parameters for CloudFormation used when deploying the bootstrap stack + * @default BootstrapEnvironmentParameters.onlyExisting() + */ + readonly parameters?: BootstrapStackParameters; + + /** + * The template source of the bootstrap stack + * + * @default BootstrapSource.default() + */ + readonly source?: { source: 'default' } | { source: 'custom'; templateFile: string }; + + /** + * Whether to execute the changeset or only create it and leave it in review + * @default true + */ + readonly execute?: boolean; + + /** + * Tags for cdktoolkit stack + * + * @default [] + */ + readonly tags?: Tag[]; + + /** + * Whether the stacks created by the bootstrap process should be protected from termination + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html + * @default true + */ + readonly terminationProtection?: boolean; +} + +/** + * Parameter values for the bootstrapping template + */ +export interface BootstrapParameters { + /** + * The name to be given to the CDK Bootstrap bucket + * By default, a name is generated by CloudFormation + * + * @default - No value, optional argument + */ + readonly bucketName?: string; + + /** + * The ID of an existing KMS key to be used for encrypting items in the bucket + * By default, the default KMS key is used + * + * @default - No value, optional argument + */ + readonly kmsKeyId?: string; + + /** + * Whether or not to create a new customer master key (CMK) + * + * Only applies to modern bootstrapping + * Legacy bootstrapping will never create a CMK, only use the default S3 key + * + * @default false + */ + readonly createCustomerMasterKey?: boolean; + + /** + * The list of AWS account IDs that are trusted to deploy into the environment being bootstrapped + * + * @default [] + */ + readonly trustedAccounts?: string[]; + + /** + * The list of AWS account IDs that are trusted to look up values in the environment being bootstrapped + * + * @default [] + */ + readonly trustedAccountsForLookup?: string[]; + + /** + * The list of AWS account IDs that should not be trusted by the bootstrapped environment + * If these accounts are already trusted, they will be removed on bootstrapping + * + * @default [] + */ + readonly untrustedAccounts?: string[]; + + /** + * The ARNs of the IAM managed policies that should be attached to the role performing CloudFormation deployments + * In most cases, this will be the AdministratorAccess policy + * At least one policy is required if `trustedAccounts` were passed + * + * @default [] + */ + readonly cloudFormationExecutionPolicies?: string[]; + + /** + * Identifier to distinguish multiple bootstrapped environments + * The default qualifier is an arbitrary but unique string + * + * @default - 'hnb659fds' + */ + readonly qualifier?: string; + + /** + * Whether or not to enable S3 Staging Bucket Public Access Block Configuration + * + * @default true + */ + readonly publicAccessBlockConfiguration?: boolean; + + /** + * Flag for using the default permissions boundary for bootstrapping + * + * @default - No value, optional argument + */ + readonly examplePermissionsBoundary?: boolean; + + /** + * Name for the customer's custom permissions boundary for bootstrapping + * + * @default - No value, optional argument + */ + readonly customPermissionsBoundary?: string; +} + +/** + * Parameters of the bootstrapping template with flexible configuration options + */ +export class BootstrapStackParameters { + /** + * Use only existing parameters on the stack. + */ + public static onlyExisting() { + return new BootstrapStackParameters({}, true); + } + + /** + * Use exactly these parameters and remove any other existing parameters from the stack. + */ + public static exactly(params: BootstrapParameters) { + return new BootstrapStackParameters(params, false); + } + + /** + * Define additional parameters for the stack, while keeping existing parameters for unspecified values. + */ + public static withExisting(params: BootstrapParameters) { + return new BootstrapStackParameters(params, true); + } + + /** + * The parameters as a Map for easy access and manipulation + */ + public readonly parameters?: BootstrapParameters; + public readonly keepExistingParameters: boolean; + + private constructor(params?: BootstrapParameters, usePreviousParameters = true) { + this.keepExistingParameters = usePreviousParameters; + this.parameters = params; + } +} + +/** + * Source configuration for bootstrap operations + */ +export class BootstrapSource { + /** + * Use the default bootstrap template + */ + static default(): BootstrapOptions['source'] { + return { source: 'default' }; + } + + /** + * Use a custom bootstrap template + */ + static customTemplate(templateFile: string): BootstrapOptions['source'] { + return { + source: 'custom', + templateFile, + }; + } +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/private/helpers.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/private/helpers.ts new file mode 100644 index 000000000..6c965319b --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/private/helpers.ts @@ -0,0 +1,24 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { ToolkitError } from '../../../api/shared-public'; + +/** + * Given a set of "/" strings, construct environments for them + */ +export function environmentsFromDescriptors(envSpecs: string[]): cxapi.Environment[] { + const ret = new Array(); + + for (const spec of envSpecs) { + const parts = spec.replace(/^aws:\/\//, '').split('/'); + if (parts.length !== 2) { + throw new ToolkitError(`Expected environment name in format 'aws:///', got: ${spec}`); + } + + ret.push({ + name: spec, + account: parts[0], + region: parts[1], + }); + } + + return ret; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/private/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/private/index.ts new file mode 100644 index 000000000..c5f595cf9 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/private/index.ts @@ -0,0 +1 @@ +export * from './helpers'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts index c40d4f6ed..add78a1be 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts @@ -1,3 +1,4 @@ +export * from './bootstrap'; export * from './deploy'; export * from './destroy'; export * from './list'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts index cc52dd849..120ece5d5 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts @@ -5,11 +5,12 @@ export { formatSdkLoggerContent, SdkProvider } from '../../../../aws-cdk/lib/api export { Context, PROJECT_CONTEXT } from '../../../../aws-cdk/lib/api/context'; export { Deployments, type SuccessfulDeployStackResult } from '../../../../aws-cdk/lib/api/deployments'; export { Settings } from '../../../../aws-cdk/lib/api/settings'; -export { tagsForStack, Tag } from '../../../../aws-cdk/lib/api/tags'; +export { type Tag, tagsForStack } from '../../../../aws-cdk/lib/api/tags'; export { DEFAULT_TOOLKIT_STACK_NAME } from '../../../../aws-cdk/lib/api/toolkit-info'; export { ResourceMigrator } from '../../../../aws-cdk/lib/api/resource-import'; export { CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../../../../aws-cdk/lib/api/logs'; export { type WorkGraph, WorkGraphBuilder, AssetBuildNode, AssetPublishNode, StackNode, Concurrency } from '../../../../aws-cdk/lib/api/work-graph'; +export { Bootstrapper } from '../../../../aws-cdk/lib/api/bootstrap'; // Context Providers export * as contextproviders from '../../../../aws-cdk/lib/context-providers'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/codes.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/codes.ts index f11d968c3..9fcc78d11 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/codes.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/codes.ts @@ -190,6 +190,21 @@ export const CODES = { }), // 9: Bootstrap + CDK_TOOLKIT_I9000: codeInfo({ + code: 'CDK_TOOLKIT_I9000', + description: 'Provides bootstrap times', + level: 'info', + }), + CDK_TOOLKIT_I9900: codeInfo({ + code: 'CDK_TOOLKIT_I9900', + description: 'Bootstrap results on success', + level: 'info', + }), + CDK_TOOLKIT_E9900: codeInfo({ + code: 'CDK_TOOLKIT_E9900', + description: 'Bootstrap failed', + level: 'error', + }), // Assembly codes CDK_ASSEMBLY_I0042: codeInfo({ diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/timer.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/timer.ts index 212b4bcde..0e62997cb 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/timer.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/timer.ts @@ -37,7 +37,7 @@ export class Timer { * Ends the current timer as a specified timing and notifies the IoHost. * @returns the elapsed time */ - public async endAs(ioHost: ActionAwareIoHost, type: 'synth' | 'deploy' | 'rollback' | 'destroy') { + public async endAs(ioHost: ActionAwareIoHost, type: 'synth' | 'deploy' | 'rollback' | 'destroy' | 'bootstrap') { const duration = this.end(); const { code, text } = timerMessageProps(type); @@ -49,7 +49,7 @@ export class Timer { } } -function timerMessageProps(type: 'synth' | 'deploy' | 'rollback'| 'destroy'): { +function timerMessageProps(type: 'synth' | 'deploy' | 'rollback'| 'destroy' | 'bootstrap'): { code: CodeInfo; text: string; } { @@ -58,5 +58,6 @@ function timerMessageProps(type: 'synth' | 'deploy' | 'rollback'| 'destroy'): { case 'deploy': return { code: CODES.CDK_TOOLKIT_I5000, text: 'Deployment' }; case 'rollback': return { code: CODES.CDK_TOOLKIT_I6000, text: 'Rollback' }; case 'destroy': return { code: CODES.CDK_TOOLKIT_I7000, text: 'Destroy' }; + case 'bootstrap': return { code: CODES.CDK_TOOLKIT_I9000, text: 'Bootstrap' }; } } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/index.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/index.ts index af7409265..012e05a6c 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/private/index.ts @@ -1,5 +1,7 @@ import { SdkProvider } from '../../api/aws-cdk'; +import { ICloudAssemblySource } from '../../api/cloud-assembly'; +import { CachedCloudAssemblySource, StackAssembly } from '../../api/cloud-assembly/private'; import { ActionAwareIoHost } from '../../api/io/private'; /** @@ -9,3 +11,21 @@ export interface ToolkitServices { sdkProvider: SdkProvider; ioHost: ActionAwareIoHost; } + +/** + * Creates a Toolkit internal CloudAssembly from a CloudAssemblySource. + * @param assemblySource the source for the cloud assembly + * @param cache if the assembly should be cached, default: `true` + * @returns the CloudAssembly object + */ +export async function assemblyFromSource(assemblySource: ICloudAssemblySource, cache: boolean = true): Promise { + if (assemblySource instanceof StackAssembly) { + return assemblySource; + } + + if (cache) { + return new StackAssembly(await new CachedCloudAssemblySource(assemblySource).produce()); + } + + return new StackAssembly(await assemblySource.produce()); +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index c2750356e..dac285dda 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -3,8 +3,9 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; import * as fs from 'fs-extra'; -import { ToolkitServices } from './private'; +import { assemblyFromSource, ToolkitServices } from './private'; import { AssemblyData, StackAndAssemblyData } from './types'; +import { BootstrapEnvironments, BootstrapOptions, BootstrapSource } from '../actions/bootstrap'; import { AssetBuildTime, type DeployOptions, RequireApproval } from '../actions/deploy'; import { type ExtendedDeployOptions, buildParameterMap, createHotswapPropertyOverrides, removePublishedAssets } from '../actions/deploy/private'; import { type DestroyOptions } from '../actions/destroy'; @@ -15,13 +16,14 @@ import { type SynthOptions } from '../actions/synth'; import { WatchOptions } from '../actions/watch'; import { patternsArrayForWatch } from '../actions/watch/private'; import { type SdkConfig } from '../api/aws-auth'; -import { DEFAULT_TOOLKIT_STACK_NAME, SdkProvider, SuccessfulDeployStackResult, StackCollection, Deployments, HotswapMode, ResourceMigrator, tagsForStack, CliIoHost, Concurrency, WorkGraphBuilder, AssetBuildNode, AssetPublishNode, StackNode, CloudWatchLogEventMonitor, findCloudWatchLogGroups, StackDetails } from '../api/aws-cdk'; +import { DEFAULT_TOOLKIT_STACK_NAME, Bootstrapper, SdkProvider, SuccessfulDeployStackResult, StackCollection, Deployments, HotswapMode, ResourceMigrator, tagsForStack, CliIoHost, Concurrency, WorkGraphBuilder, AssetBuildNode, AssetPublishNode, StackNode, CloudWatchLogEventMonitor, findCloudWatchLogGroups, StackDetails } from '../api/aws-cdk'; import { ICloudAssemblySource, StackSelectionStrategy } from '../api/cloud-assembly'; -import { ALL_STACKS, CachedCloudAssemblySource, CloudAssemblySourceBuilder, IdentityCloudAssemblySource, StackAssembly } from '../api/cloud-assembly/private'; +import { ALL_STACKS, CloudAssemblySourceBuilder, IdentityCloudAssemblySource, StackAssembly } from '../api/cloud-assembly/private'; import { IIoHost, IoMessageCode, IoMessageLevel } from '../api/io'; import { asSdkLogger, withAction, Timer, confirm, error, info, success, warn, ActionAwareIoHost, debug, result, withoutEmojis, withoutColor, withTrimmedWhitespace, CODES } from '../api/io/private'; import { ToolkitError } from '../api/shared-public'; import { obscureTemplate, serializeStructure, validateSnsTopicArn, formatTime, formatErrorMessage } from '../private/util'; +import { pLimit } from '../util/concurrency'; /** * The current action being performed by the CLI. 'none' represents the absence of an action. @@ -155,13 +157,55 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab }; } + /** + * Bootstrap Action + */ + public async bootstrap(environments: BootstrapEnvironments, options: BootstrapOptions): Promise { + const ioHost = withAction(this.ioHost, 'bootstrap'); + const bootstrapEnvironments = await environments.getEnvironments(); + const source = options.source ?? BootstrapSource.default(); + const parameters = options.parameters; + const bootstrapper = new Bootstrapper(source, { ioHost, action: 'bootstrap' }); + const sdkProvider = await this.sdkProvider('bootstrap'); + const limit = pLimit(20); + + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + await Promise.all(bootstrapEnvironments.map((environment: cxapi.Environment) => limit(async () => { + await ioHost.notify(info(`${chalk.bold(environment.name)}: bootstrapping...`)); + const bootstrapTimer = Timer.start(); + + try { + const bootstrapResult = await bootstrapper.bootstrapEnvironment( + environment, + sdkProvider, + { + ...options, + toolkitStackName: this.toolkitStackName, + source, + parameters: parameters?.parameters, + usePreviousParameters: parameters?.keepExistingParameters, + }, + ); + const message = bootstrapResult.noOp + ? ` ✅ ${environment.name} (no changes)` + : ` ✅ ${environment.name}`; + + await ioHost.notify(result(chalk.green('\n' + message), CODES.CDK_TOOLKIT_I9900, { environment })); + await bootstrapTimer.endAs(ioHost, 'bootstrap'); + } catch (e) { + await ioHost.notify(error(`\n ❌ ${chalk.bold(environment.name)} failed: ${formatErrorMessage(e)}`, CODES.CDK_TOOLKIT_E9900)); + throw e; + } + }))); + } + /** * Synth Action */ public async synth(cx: ICloudAssemblySource, options: SynthOptions = {}): Promise { const ioHost = withAction(this.ioHost, 'synth'); const synthTimer = Timer.start(); - const assembly = await this.assemblyFromSource(cx); + const assembly = await assemblyFromSource(cx); const stacks = assembly.selectStacksV2(options.stacks ?? ALL_STACKS); const autoValidateStacks = options.validateStacks ? [assembly.selectStacksForValidation()] : []; await this.validateStacksMetadata(stacks.concat(...autoValidateStacks), ioHost); @@ -206,7 +250,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab public async list(cx: ICloudAssemblySource, options: ListOptions = {}): Promise { const ioHost = withAction(this.ioHost, 'list'); const synthTimer = Timer.start(); - const assembly = await this.assemblyFromSource(cx); + const assembly = await assemblyFromSource(cx); const stackCollection = await assembly.selectStacksV2(options.stacks ?? ALL_STACKS); await synthTimer.endAs(ioHost, 'synth'); @@ -223,7 +267,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab * Deploys the selected stacks into an AWS account */ public async deploy(cx: ICloudAssemblySource, options: DeployOptions = {}): Promise { - const assembly = await this.assemblyFromSource(cx); + const assembly = await assemblyFromSource(cx); return this._deploy(assembly, 'deploy', options); } @@ -525,7 +569,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab * Implies hotswap deployments. */ public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise { - const assembly = await this.assemblyFromSource(cx, false); + const assembly = await assemblyFromSource(cx, false); const ioHost = withAction(this.ioHost, 'watch'); const rootDir = options.watchDir ?? process.cwd(); await ioHost.notify(debug(`root directory used for 'watch' is: ${rootDir}`)); @@ -627,7 +671,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab * Rolls back the selected stacks. */ public async rollback(cx: ICloudAssemblySource, options: RollbackOptions): Promise { - const assembly = await this.assemblyFromSource(cx); + const assembly = await assemblyFromSource(cx); return this._rollback(assembly, 'rollback', options); } @@ -681,7 +725,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab * Destroys the selected Stacks. */ public async destroy(cx: ICloudAssemblySource, options: DestroyOptions): Promise { - const assembly = await this.assemblyFromSource(cx); + const assembly = await assemblyFromSource(cx); return this._destroy(assembly, 'destroy', options); } @@ -746,24 +790,6 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab })); } - /** - * Creates a Toolkit internal CloudAssembly from a CloudAssemblySource. - * @param assemblySource the source for the cloud assembly - * @param cache if the assembly should be cached, default: `true` - * @returns the CloudAssembly object - */ - private async assemblyFromSource(assemblySource: ICloudAssemblySource, cache: boolean = true): Promise { - if (assemblySource instanceof StackAssembly) { - return assemblySource; - } - - if (cache) { - return new StackAssembly(await new CachedCloudAssemblySource(assemblySource).produce()); - } - - return new StackAssembly(await assemblySource.produce()); - } - /** * Create a deployments class */ diff --git a/packages/@aws-cdk/toolkit-lib/lib/util/concurrency.ts b/packages/@aws-cdk/toolkit-lib/lib/util/concurrency.ts new file mode 100644 index 000000000..e59afc99a --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/util/concurrency.ts @@ -0,0 +1,7 @@ +/** + * Re-export p-limit for concurrency control + */ +// Must use a require() otherwise esbuild complains about calling a namespace +// eslint-disable-next-line @typescript-eslint/no-require-imports +const pLimit: typeof import('p-limit') = require('p-limit'); +export { pLimit }; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/_fixtures/custom-bootstrap-template.yaml b/packages/@aws-cdk/toolkit-lib/test/actions/_fixtures/custom-bootstrap-template.yaml new file mode 100644 index 000000000..b2c2949c9 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/actions/_fixtures/custom-bootstrap-template.yaml @@ -0,0 +1,99 @@ +Description: Custom CDK Bootstrap Template + +Parameters: + Qualifier: + Type: String + Description: Qualifier for the bootstrap resources + Default: custom + + CustomTagKey: + Type: String + Description: Key for a custom tag to apply to all resources + Default: Environment + + CustomTagValue: + Type: String + Description: Value for the custom tag + Default: Development + +Resources: + StagingBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::Sub: cdk-${Qualifier}-assets-${AWS::AccountId}-${AWS::Region} + VersioningConfiguration: + Status: Enabled + LifecycleConfiguration: + Rules: + - Id: DeleteOldVersions + Status: Enabled + NoncurrentVersionExpiration: + NoncurrentDays: 90 + Tags: + - Key: + Ref: CustomTagKey + Value: + Ref: CustomTagValue + + ContainerAssetsRepository: + Type: AWS::ECR::Repository + Properties: + RepositoryName: + Fn::Sub: cdk-${Qualifier}-container-assets-${AWS::AccountId}-${AWS::Region} + ImageScanningConfiguration: + ScanOnPush: true + Tags: + - Key: + Ref: CustomTagKey + Value: + Ref: CustomTagValue + + CloudFormationExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess + RoleName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: + Ref: CustomTagKey + Value: + Ref: CustomTagValue + + CdkBootstrapVersion: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: + Fn::Sub: /cdk-bootstrap/${Qualifier}/version + Value: '1' + +Outputs: + BucketName: + Description: The name of the S3 bucket for CDK assets + Value: + Ref: StagingBucket + + RepositoryName: + Description: The name of the ECR repository for container assets + Value: + Ref: ContainerAssetsRepository + + CloudFormationExecutionRoleArn: + Description: The ARN of the CloudFormation execution role + Value: + Fn::GetAtt: [CloudFormationExecutionRole, Arn] + + BootstrapVersion: + Description: The version of this bootstrap stack + Value: + Fn::GetAtt: [CdkBootstrapVersion, Value] diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/_fixtures/invalid-bootstrap-template.yaml b/packages/@aws-cdk/toolkit-lib/test/actions/_fixtures/invalid-bootstrap-template.yaml new file mode 100644 index 000000000..4715d89e2 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/actions/_fixtures/invalid-bootstrap-template.yaml @@ -0,0 +1 @@ +not a valid template diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/bootstrap.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/bootstrap.test.ts new file mode 100644 index 000000000..4e32790ca --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/actions/bootstrap.test.ts @@ -0,0 +1,528 @@ +import * as path from 'node:path'; +import { + CreateChangeSetCommand, + DeleteChangeSetCommand, + DescribeChangeSetCommand, + DescribeStacksCommand, + ExecuteChangeSetCommand, + Stack, +} from '@aws-sdk/client-cloudformation'; +import { bold } from 'chalk'; +import { BootstrapEnvironments, BootstrapOptions, BootstrapSource, BootstrapStackParameters } from '../../lib/actions/bootstrap'; +import { SdkProvider } from '../../lib/api/aws-cdk'; +import { Toolkit } from '../../lib/toolkit'; +import { TestIoHost, builderFixture } from '../_helpers'; +import { + MockSdkProvider, + MockSdk, + mockCloudFormationClient, + restoreSdkMocksToDefault, + setDefaultSTSMocks, +} from '../util/aws-cdk'; + +const ioHost = new TestIoHost(); +const toolkit = new Toolkit({ ioHost }); +const mockSdkProvider = new MockSdkProvider(); + +// we don't need to use AWS CLI compatible defaults here, since everything is mocked anyway +jest.spyOn(SdkProvider, 'withAwsCliCompatibleDefaults').mockResolvedValue(mockSdkProvider); + +beforeEach(() => { + restoreSdkMocksToDefault(); + setDefaultSTSMocks(); + ioHost.notifySpy.mockClear(); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +function setupMockCloudFormationClient(mockStack: Stack) { + mockCloudFormationClient + .on(DescribeStacksCommand) + .resolves({ Stacks: [] }) // First call - stack doesn't exist + .on(CreateChangeSetCommand) + .resolves({ Id: 'CHANGESET_ID' }) + .on(DescribeChangeSetCommand) + .resolves({ + Status: 'CREATE_COMPLETE', + Changes: [{ ResourceChange: { Action: 'Add' } }], + ExecutionStatus: 'AVAILABLE', + }) + .on(ExecuteChangeSetCommand) + .resolves({}) + .on(DescribeStacksCommand) + .resolves({ // Stack is in progress + Stacks: [{ + ...mockStack, + StackStatus: 'CREATE_IN_PROGRESS', + }], + }) + .on(DescribeStacksCommand) + .resolves({ // Final state - stack is complete + Stacks: [{ + ...mockStack, + StackStatus: 'CREATE_COMPLETE', + }], + }); +} + +function createMockStack(outputs: { OutputKey: string; OutputValue: string }[]): Stack { + return { + StackId: 'mock-stack-id', + StackName: 'CDKToolkit', + CreationTime: new Date(), + LastUpdatedTime: new Date(), + Outputs: outputs, + } as Stack; +} + +async function runBootstrap(options?: { + environments?: string[]; + source?: BootstrapOptions['source']; + parameters?: BootstrapStackParameters; +}) { + const cx = await builderFixture(toolkit, 'stack-with-asset'); + const bootstrapEnvs = options?.environments?.length ? + BootstrapEnvironments.fromList(options.environments) : BootstrapEnvironments.fromCloudAssemblySource(cx); + return toolkit.bootstrap(bootstrapEnvs, { + source: options?.source, + parameters: options?.parameters, + }); +} + +function expectSuccessfulBootstrap() { + expect(mockCloudFormationClient.calls().length).toBeGreaterThan(0); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('bootstrapping...'), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('✅'), + })); +} + +describe('bootstrap', () => { + describe('with user-specified environments', () => { + const originalSdk = mockSdkProvider.forEnvironment.bind(mockSdkProvider); + beforeEach(() => { + const mockForEnvironment = jest.fn().mockImplementation(() => { + return { sdk: new MockSdk() }; + }); + mockSdkProvider.forEnvironment = mockForEnvironment; + }); + + afterAll(() => { + mockSdkProvider.forEnvironment = originalSdk; + }); + + test('bootstraps specified environments', async () => { + // GIVEN + const mockStack1 = createMockStack([ + { OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME_1' }, + { OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT_1' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ]); + const mockStack2 = createMockStack([ + { OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME_2' }, + { OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT_2' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ]); + setupMockCloudFormationClient(mockStack1); + setupMockCloudFormationClient(mockStack2); + + // WHEN + await runBootstrap({ environments: ['aws://123456789012/us-east-1', 'aws://234567890123/eu-west-1'] }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining(`${bold('aws://123456789012/us-east-1')}: bootstrapping...`), + })); + + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining(`${bold('aws://234567890123/eu-west-1')}: bootstrapping...`), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_I9900', + message: expect.stringContaining('✅'), + data: expect.objectContaining({ + environment: { + name: 'aws://123456789012/us-east-1', + account: '123456789012', + region: 'us-east-1', + }, + }), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_I9900', + message: expect.stringContaining('✅'), + data: expect.objectContaining({ + environment: { + name: 'aws://234567890123/eu-west-1', + account: '234567890123', + region: 'eu-west-1', + }, + }), + })); + }); + + test('handles errors in user-specified environments', async () => { + // GIVEN + const mockStack = createMockStack([ + { OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' }, + { OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ]); + setupMockCloudFormationClient(mockStack); + + // Mock an access denied error + const accessDeniedError = new Error('Access Denied'); + accessDeniedError.name = 'AccessDeniedException'; + mockCloudFormationClient + .on(CreateChangeSetCommand) + .rejects(accessDeniedError); + + // WHEN/THEN + await expect(runBootstrap({ environments: ['aws://123456789012/us-east-1'] })) + .rejects.toThrow('Access Denied'); + + // Get all error notifications + const errorCalls = ioHost.notifySpy.mock.calls + .filter(call => call[0].level === 'error') + .map(call => call[0]); + + // Verify error notifications + expect(errorCalls).toContainEqual(expect.objectContaining({ + level: 'error', + message: expect.stringContaining('❌'), + })); + expect(errorCalls).toContainEqual(expect.objectContaining({ + level: 'error', + message: expect.stringContaining(`${bold('aws://123456789012/us-east-1')} failed: Access Denied`), + })); + }); + + test('throws error for invalid environment format', async () => { + // WHEN/THEN + await expect(runBootstrap({ environments: ['invalid-format'] })) + .rejects.toThrow('Expected environment name in format \'aws:///\', got: invalid-format'); + }); + }); + + describe('bootstrap parameters', () => { + test('bootstrap with default parameters', async () => { + // GIVEN + const mockStack = createMockStack([ + { OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' }, + { OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ]); + setupMockCloudFormationClient(mockStack); + + // WHEN + await runBootstrap(); + + // THEN + const createChangeSetCalls = mockCloudFormationClient.calls().filter(call => call.args[0] instanceof CreateChangeSetCommand); + expect(createChangeSetCalls.length).toBeGreaterThan(0); + const parameters = (createChangeSetCalls[0].args[0].input as any).Parameters; + // Default parameters should include standard bootstrap parameters + expect(new Set(parameters)).toEqual(new Set([ + { + ParameterKey: 'TrustedAccounts', + ParameterValue: '', + }, + { + ParameterKey: 'TrustedAccountsForLookup', + ParameterValue: '', + }, + { + ParameterKey: 'CloudFormationExecutionPolicies', + ParameterValue: '', + }, + { + ParameterKey: 'FileAssetsBucketKmsKeyId', + ParameterValue: 'AWS_MANAGED_KEY', + }, + { + ParameterKey: 'PublicAccessBlockConfiguration', + ParameterValue: 'true', + }, + ])); + expectSuccessfulBootstrap(); + }); + + test('bootstrap with exact parameters', async () => { + // GIVEN + const mockStack = createMockStack([ + { OutputKey: 'BucketName', OutputValue: 'CUSTOM_BUCKET' }, + { OutputKey: 'BucketDomainName', OutputValue: 'CUSTOM_ENDPOINT' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ]); + setupMockCloudFormationClient(mockStack); + + const customParams = { + bucketName: 'custom-bucket', + qualifier: 'test', + publicAccessBlockConfiguration: false, + }; + + // WHEN + await runBootstrap({ + parameters: BootstrapStackParameters.exactly(customParams), + }); + + // THEN + const createChangeSetCalls = mockCloudFormationClient.calls().filter(call => call.args[0] instanceof CreateChangeSetCommand); + expect(createChangeSetCalls.length).toBeGreaterThan(0); + const parameters = (createChangeSetCalls[0].args[0].input as any).Parameters; + // For exact parameters, we should see our custom values + expect(parameters).toContainEqual({ + ParameterKey: 'FileAssetsBucketName', + ParameterValue: 'custom-bucket', + }); + expect(parameters).toContainEqual({ + ParameterKey: 'Qualifier', + ParameterValue: 'test', + }); + expect(parameters).toContainEqual({ + ParameterKey: 'PublicAccessBlockConfiguration', + ParameterValue: 'false', + }); + expectSuccessfulBootstrap(); + }); + + test('bootstrap with additional parameters', async () => { + // GIVEN + const mockStack = createMockStack([ + { OutputKey: 'BucketName', OutputValue: 'EXISTING_BUCKET' }, + { OutputKey: 'BucketDomainName', OutputValue: 'EXISTING_ENDPOINT' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ]); + setupMockCloudFormationClient(mockStack); + + const additionalParams = { + qualifier: 'additional', + trustedAccounts: ['123456789012'], + cloudFormationExecutionPolicies: ['arn:aws:iam::aws:policy/AdministratorAccess'], + }; + + // WHEN + await runBootstrap({ + parameters: BootstrapStackParameters.withExisting(additionalParams), + }); + + // THEN + const createChangeSetCalls = mockCloudFormationClient.calls().filter(call => call.args[0] instanceof CreateChangeSetCommand); + expect(createChangeSetCalls.length).toBeGreaterThan(0); + const parameters = (createChangeSetCalls[0].args[0].input as any).Parameters; + // For additional parameters, we should see our new values merged with defaults + expect(parameters).toContainEqual({ + ParameterKey: 'Qualifier', + ParameterValue: 'additional', + }); + expect(parameters).toContainEqual({ + ParameterKey: 'TrustedAccounts', + ParameterValue: '123456789012', + }); + expect(parameters).toContainEqual({ + ParameterKey: 'CloudFormationExecutionPolicies', + ParameterValue: 'arn:aws:iam::aws:policy/AdministratorAccess', + }); + expectSuccessfulBootstrap(); + }); + + test('bootstrap with only existing parameters', async () => { + // GIVEN + const mockStack = createMockStack([ + { OutputKey: 'BucketName', OutputValue: 'EXISTING_BUCKET' }, + { OutputKey: 'BucketDomainName', OutputValue: 'EXISTING_ENDPOINT' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ]); + setupMockCloudFormationClient(mockStack); + + // WHEN + await runBootstrap({ + parameters: BootstrapStackParameters.onlyExisting(), + }); + + // THEN + const createChangeSetCalls = mockCloudFormationClient.calls().filter(call => call.args[0] instanceof CreateChangeSetCommand); + expect(createChangeSetCalls.length).toBeGreaterThan(0); + const parameters = (createChangeSetCalls[0].args[0].input as any).Parameters; + // When using only existing parameters, we should get the default set + expect(new Set(parameters)).toEqual(new Set([ + { + ParameterKey: 'TrustedAccounts', + ParameterValue: '', + }, + { + ParameterKey: 'TrustedAccountsForLookup', + ParameterValue: '', + }, + { + ParameterKey: 'CloudFormationExecutionPolicies', + ParameterValue: '', + }, + { + ParameterKey: 'FileAssetsBucketKmsKeyId', + ParameterValue: 'AWS_MANAGED_KEY', + }, + { + ParameterKey: 'PublicAccessBlockConfiguration', + ParameterValue: 'true', + }, + ])); + expectSuccessfulBootstrap(); + }); + }); + + describe('template sources', () => { + test('uses default template when no source is specified', async () => { + // GIVEN + const mockStack = createMockStack([ + { OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' }, + { OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ]); + setupMockCloudFormationClient(mockStack); + + // WHEN + await runBootstrap(); + + // THEN + expectSuccessfulBootstrap(); + }); + + test('uses custom template when specified', async () => { + // GIVEN + const mockStack = createMockStack([ + { OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' }, + { OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ]); + setupMockCloudFormationClient(mockStack); + + // WHEN + await runBootstrap({ + source: BootstrapSource.customTemplate(path.join(__dirname, '_fixtures', 'custom-bootstrap-template.yaml')), + }); + + // THEN + const createChangeSetCalls = mockCloudFormationClient.calls().filter(call => call.args[0] instanceof CreateChangeSetCommand); + expect(createChangeSetCalls.length).toBeGreaterThan(0); + expectSuccessfulBootstrap(); + }); + + test('handles errors with custom template', async () => { + // GIVEN + const templateError = new Error('Invalid template file'); + mockCloudFormationClient + .on(DescribeStacksCommand) + .rejects(templateError); + + // WHEN + await expect(runBootstrap({ + source: BootstrapSource.customTemplate(path.join(__dirname, '_fixtures', 'invalid-bootstrap-template.yaml')), + })).rejects.toThrow('Invalid template file'); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'error', + message: expect.stringContaining('❌'), + })); + }); + }); + + test('bootstrap handles no-op scenarios', async () => { + // GIVEN + const mockExistingStack = { + StackId: 'mock-stack-id', + StackName: 'CDKToolkit', + StackStatus: 'CREATE_COMPLETE', + CreationTime: new Date(), + LastUpdatedTime: new Date(), + Outputs: [ + { OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' }, + { OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' }, + { OutputKey: 'BootstrapVersion', OutputValue: '1' }, + ], + } as Stack; + + // First describe call to check if stack exists + mockCloudFormationClient + .on(DescribeStacksCommand) + .resolves({ Stacks: [mockExistingStack] }); + + // Create changeset call + mockCloudFormationClient + .on(CreateChangeSetCommand) + .resolves({ Id: 'CHANGESET_ID', StackId: mockExistingStack.StackId }); + + // Describe changeset call - indicate no changes + mockCloudFormationClient + .on(DescribeChangeSetCommand) + .resolves({ + Status: 'FAILED', + StatusReason: 'No updates are to be performed.', + Changes: [], + ExecutionStatus: 'UNAVAILABLE', + StackId: mockExistingStack.StackId, + ChangeSetId: 'CHANGESET_ID', + }); + + // Delete changeset call after no changes detected + mockCloudFormationClient + .on(DeleteChangeSetCommand) + .resolves({}); + + // Final describe call to get outputs + mockCloudFormationClient + .on(DescribeStacksCommand) + .resolves({ Stacks: [mockExistingStack] }); + + // WHEN + await runBootstrap(); + + // THEN + expectSuccessfulBootstrap(); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('(no changes)'), + })); + }); + + describe('error handling', () => { + test('handles generic bootstrap errors', async () => { + // GIVEN + mockCloudFormationClient.onAnyCommand().rejects(new Error('Bootstrap failed')); + + // WHEN + await expect(runBootstrap()).rejects.toThrow('Bootstrap failed'); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'error', + message: expect.stringContaining('❌'), + })); + }); + + test('handles permission errors', async () => { + // GIVEN + const permissionError = new Error('Access Denied'); + permissionError.name = 'AccessDeniedException'; + mockCloudFormationClient.onAnyCommand().rejects(permissionError); + + // WHEN + await expect(runBootstrap()).rejects.toThrow('Access Denied'); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'error', + message: expect.stringContaining('❌'), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'error', + message: expect.stringContaining('Access Denied'), + })); + }); + }); +}); diff --git a/packages/@aws-cdk/toolkit-lib/test/util/aws-cdk.ts b/packages/@aws-cdk/toolkit-lib/test/util/aws-cdk.ts index fa54a188f..d7020bad6 100644 --- a/packages/@aws-cdk/toolkit-lib/test/util/aws-cdk.ts +++ b/packages/@aws-cdk/toolkit-lib/test/util/aws-cdk.ts @@ -1,3 +1,12 @@ /* eslint-disable import/no-restricted-paths */ -export { MockSdk } from '../../../../aws-cdk/test/util/mock-sdk'; +// Local modules +export { + MockSdk, + MockSdkProvider, + mockCloudFormationClient, + mockS3Client, + mockSTSClient, + setDefaultSTSMocks, + restoreSdkMocksToDefault, +} from '../../../../aws-cdk/test/util/mock-sdk'; diff --git a/packages/aws-cdk/lib/api/aws-auth/user-agent.ts b/packages/aws-cdk/lib/api/aws-auth/user-agent.ts index a63601234..8b9385657 100644 --- a/packages/aws-cdk/lib/api/aws-auth/user-agent.ts +++ b/packages/aws-cdk/lib/api/aws-auth/user-agent.ts @@ -1,7 +1,6 @@ import * as path from 'path'; import { readIfPossible } from './util'; -import { cliRootDir } from '../../cli/root-dir'; - +import { bundledPackageRootDir } from '../../util'; /** * Find the package.json from the main toolkit. * @@ -9,7 +8,7 @@ import { cliRootDir } from '../../cli/root-dir'; * Fall back to argv[1], or a standard string if that is undefined for some reason. */ export function defaultCliUserAgent() { - const root = cliRootDir(false); + const root = bundledPackageRootDir(__dirname, false); const pkg = JSON.parse((root ? readIfPossible(path.join(root, 'package.json')) : undefined) ?? '{}'); const name = pkg.name ?? path.basename(process.argv[1] ?? 'cdk-cli'); const version = pkg.version ?? ''; diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts b/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts index ec04e15d0..d62071184 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts @@ -5,10 +5,9 @@ import type { BootstrapEnvironmentOptions, BootstrappingParameters } from './boo import { BootstrapStack, bootstrapVersionFromTemplate } from './deploy-bootstrap'; import { legacyBootstrapTemplate } from './legacy-template'; import { warn } from '../../cli/messages'; -import { cliRootDir } from '../../cli/root-dir'; import { IoMessaging } from '../../toolkit/cli-io-host'; import { ToolkitError } from '../../toolkit/error'; -import { loadStructuredFile, serializeStructure } from '../../util'; +import { bundledPackageRootDir, loadStructuredFile, serializeStructure } from '../../util'; import type { SDK, SdkProvider } from '../aws-auth'; import type { SuccessfulDeployStackResult } from '../deployments'; import { Mode } from '../plugin/mode'; @@ -375,7 +374,7 @@ export class Bootstrapper { case 'custom': return loadStructuredFile(this.source.templateFile); case 'default': - return loadStructuredFile(path.join(cliRootDir(), 'lib', 'api', 'bootstrap', 'bootstrap-template.yaml')); + return loadStructuredFile(path.join(bundledPackageRootDir(__dirname), 'lib', 'api', 'bootstrap', 'bootstrap-template.yaml')); case 'legacy': return legacyBootstrapTemplate(params); } diff --git a/packages/aws-cdk/test/util/mock-sdk.ts b/packages/aws-cdk/test/util/mock-sdk.ts index 9666a6e0e..db6f20202 100644 --- a/packages/aws-cdk/test/util/mock-sdk.ts +++ b/packages/aws-cdk/test/util/mock-sdk.ts @@ -81,7 +81,6 @@ export const restoreSdkMocksToDefault = () => { mockS3Client.onAnyCommand().resolves({}); mockSecretsManagerClient.onAnyCommand().resolves({}); mockSSMClient.onAnyCommand().resolves({}); - mockSSMClient.onAnyCommand().resolves({}); }; /**