packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts (812 lines of code) (raw):

import '../private/dispose-polyfill'; import * as path from 'node:path'; import type { TemplateDiff } from '@aws-cdk/cloudformation-diff'; 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 { NonInteractiveIoHost } from './non-interactive-io-host'; import type { ToolkitServices } from './private'; import { assemblyFromSource } from './private'; import type { DeployResult, DestroyResult, RollbackResult } from './types'; import type { BootstrapEnvironments, BootstrapOptions, BootstrapResult, EnvironmentBootstrapResult, } from '../actions/bootstrap'; import { BootstrapSource } from '../actions/bootstrap'; import { AssetBuildTime, HotswapMode, type DeployOptions } from '../actions/deploy'; import { buildParameterMap, createHotswapPropertyOverrides, type ExtendedDeployOptions, removePublishedAssetsFromWorkGraph, } from '../actions/deploy/private'; import { type DestroyOptions } from '../actions/destroy'; import type { DiffOptions } from '../actions/diff'; import { appendObject, prepareDiff } from '../actions/diff/private'; import { type ListOptions } from '../actions/list'; import type { RefactorOptions } from '../actions/refactor'; import { type RollbackOptions } from '../actions/rollback'; import { type SynthOptions } from '../actions/synth'; import type { WatchOptions } from '../actions/watch'; import { patternsArrayForWatch } from '../actions/watch/private'; import { BaseCredentials, type SdkConfig } from '../api/aws-auth'; import { makeRequestHandler } from '../api/aws-auth/awscli-compatible'; import type { SdkProviderServices } from '../api/aws-auth/private'; import { SdkProvider } from '../api/aws-auth/private'; import { Bootstrapper } from '../api/bootstrap'; import type { ICloudAssemblySource } from '../api/cloud-assembly'; import { CachedCloudAssembly, StackSelectionStrategy } from '../api/cloud-assembly'; import type { StackAssembly } from '../api/cloud-assembly/private'; import { ALL_STACKS, CloudAssemblySourceBuilder } from '../api/cloud-assembly/private'; import type { StackCollection } from '../api/cloud-assembly/stack-collection'; import { Deployments } from '../api/deployments'; import { DiffFormatter } from '../api/diff'; import type { IIoHost, IoMessageLevel } from '../api/io'; import type { IoHelper } from '../api/io/private'; import { asIoHelper, asSdkLogger, IO, SPAN, withoutColor, withoutEmojis, withTrimmedWhitespace } from '../api/io/private'; import { CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../api/logs-monitor'; import { AmbiguityError, ambiguousMovements, findResourceMovements, formatAmbiguousMappings, formatTypedMappings, fromManifestAndExclusionList, resourceMappings } from '../api/refactoring'; import { ResourceMigrator } from '../api/resource-import'; import type { AssemblyData, StackDetails, SuccessfulDeployStackResult, ToolkitAction } from '../api/shared-public'; import { PermissionChangeType, PluginHost, ToolkitError } from '../api/shared-public'; import { tagsForStack } from '../api/tags'; import { DEFAULT_TOOLKIT_STACK_NAME } from '../api/toolkit-info'; import type { Concurrency, AssetBuildNode, AssetPublishNode, StackNode } from '../api/work-graph'; import { WorkGraphBuilder } from '../api/work-graph'; import { formatErrorMessage, formatTime, obscureTemplate, serializeStructure, validateSnsTopicArn, } from '../private/util'; import { pLimit } from '../util/concurrency'; import { promiseWithResolvers } from '../util/promises'; export interface ToolkitOptions { /** * The IoHost implementation, handling the inline interactions between the Toolkit and an integration. */ readonly ioHost?: IIoHost; /** * Allow emojis in messages sent to the IoHost. * * @default true */ readonly emojis?: boolean; /** * Whether to allow ANSI colors and formatting in IoHost messages. * Setting this value to `false` enforces that no color or style shows up * in messages sent to the IoHost. * Setting this value to true is a no-op; it is equivalent to the default. * * @default - detects color from the TTY status of the IoHost */ readonly color?: boolean; /** * Configuration options for the SDK. */ readonly sdkConfig?: SdkConfig; /** * Name of the toolkit stack to be used. * * @default "CDKToolkit" */ readonly toolkitStackName?: string; /** * Fail Cloud Assemblies * * @default "error" */ readonly assemblyFailureAt?: 'error' | 'warn' | 'none'; /** * The plugin host to use for loading and querying plugins * * By default, a unique instance of a plugin managing class will be used. * * Use `toolkit.pluginHost.load()` to load plugins into the plugin host from disk. * * @default - A fresh plugin host */ readonly pluginHost?: PluginHost; } /** * The AWS CDK Programmatic Toolkit */ export class Toolkit extends CloudAssemblySourceBuilder { /** * The toolkit stack name used for bootstrapping resources. */ public readonly toolkitStackName: string; /** * The IoHost of this Toolkit */ public readonly ioHost: IIoHost; /** * The plugin host for loading and managing plugins */ public readonly pluginHost: PluginHost; /** * Cache of the internal SDK Provider instance */ private sdkProviderCache?: SdkProvider; private baseCredentials: BaseCredentials; public constructor(private readonly props: ToolkitOptions = {}) { super(); this.toolkitStackName = props.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME; this.pluginHost = props.pluginHost ?? new PluginHost(); let ioHost = props.ioHost ?? new NonInteractiveIoHost(); if (props.emojis === false) { ioHost = withoutEmojis(ioHost); } if (props.color === false) { ioHost = withoutColor(ioHost); } // After removing emojis and color, we might end up with floating whitespace at either end of the message // This also removes newlines that we currently emit for CLI backwards compatibility. this.ioHost = withTrimmedWhitespace(ioHost); if (props.sdkConfig?.profile && props.sdkConfig?.baseCredentials) { throw new ToolkitError('Specify at most one of \'sdkConfig.profile\' and \'sdkConfig.baseCredentials\''); } this.baseCredentials = props.sdkConfig?.baseCredentials ?? BaseCredentials.awsCliCompatible({ profile: props.sdkConfig?.profile }); } /** * Access to the AWS SDK * @internal */ protected async sdkProvider(action: ToolkitAction): Promise<SdkProvider> { // @todo this needs to be different instance per action if (!this.sdkProviderCache) { const ioHelper = asIoHelper(this.ioHost, action); const services: SdkProviderServices = { ioHelper, requestHandler: await makeRequestHandler(ioHelper, this.props.sdkConfig?.httpOptions), logger: asSdkLogger(ioHelper), pluginHost: this.pluginHost, }; const config = await this.baseCredentials.makeSdkConfig(services); this.sdkProviderCache = new SdkProvider(config.credentialProvider, config.defaultRegion, services); } return this.sdkProviderCache; } /** * Helper to provide the CloudAssemblySourceBuilder with required toolkit services * @internal */ protected override async sourceBuilderServices(): Promise<ToolkitServices> { return { ioHelper: asIoHelper(this.ioHost, 'assembly'), sdkProvider: await this.sdkProvider('assembly'), pluginHost: this.pluginHost, }; } /** * Bootstrap Action */ public async bootstrap(environments: BootstrapEnvironments, options: BootstrapOptions): Promise<BootstrapResult> { const startTime = Date.now(); const results: EnvironmentBootstrapResult[] = []; const ioHelper = asIoHelper(this.ioHost, 'bootstrap'); const bootstrapEnvironments = await environments.getEnvironments(this.ioHost); const source = options.source ?? BootstrapSource.default(); const parameters = options.parameters; const bootstrapper = new Bootstrapper(source, ioHelper); 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, currentIdx) => limit(async () => { const bootstrapSpan = await ioHelper.span(SPAN.BOOTSTRAP_SINGLE) .begin(`${chalk.bold(environment.name)}: bootstrapping...`, { total: bootstrapEnvironments.length, current: currentIdx + 1, environment, }); 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 ioHelper.notify(IO.CDK_TOOLKIT_I9900.msg(chalk.green('\n' + message), { environment })); const envTime = await bootstrapSpan.end(); const result: EnvironmentBootstrapResult = { environment, status: bootstrapResult.noOp ? 'no-op' : 'success', duration: envTime.asMs, }; results.push(result); } catch (e: any) { await ioHelper.notify(IO.CDK_TOOLKIT_E9900.msg(`\n ❌ ${chalk.bold(environment.name)} failed: ${formatErrorMessage(e)}`, { error: e })); throw e; } }))); return { environments: results, duration: Date.now() - startTime, }; } /** * Synth Action * * The caller assumes ownership of the `CachedCloudAssembly` and is responsible for calling `dispose()` on * it after use. */ public async synth(cx: ICloudAssemblySource, options: SynthOptions = {}): Promise<CachedCloudAssembly> { const ioHelper = asIoHelper(this.ioHost, 'synth'); const selectStacks = options.stacks ?? ALL_STACKS; const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: selectStacks }); // NOTE: NOT 'await using' because we return ownership to the caller const assembly = await assemblyFromSource(ioHelper, cx); const stacks = await assembly.selectStacksV2(selectStacks); const autoValidateStacks = options.validateStacks ? [assembly.selectStacksForValidation()] : []; await this.validateStacksMetadata(stacks.concat(...autoValidateStacks), ioHelper); await synthSpan.end(); // if we have a single stack, print it to STDOUT const message = `Successfully synthesized to ${chalk.blue(path.resolve(stacks.assembly.directory))}`; const assemblyData: AssemblyData = { assemblyDirectory: stacks.assembly.directory, stacksCount: stacks.stackCount, stackIds: stacks.hierarchicalIds, }; if (stacks.stackCount === 1) { const firstStack = stacks.firstStack!; const template = firstStack.template; const obscuredTemplate = obscureTemplate(template); await ioHelper.notify(IO.CDK_TOOLKIT_I1901.msg(message, { ...assemblyData, stack: { stackName: firstStack.stackName, hierarchicalId: firstStack.hierarchicalId, template, stringifiedJson: serializeStructure(obscuredTemplate, true), stringifiedYaml: serializeStructure(obscuredTemplate, false), }, })); } else { // not outputting template to stdout, let's explain things to the user a little bit... await ioHelper.notify(IO.CDK_TOOLKIT_I1902.msg(chalk.green(message), assemblyData)); await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(`Supply a stack id (${stacks.stackArtifacts.map((s) => chalk.green(s.hierarchicalId)).join(', ')}) to display its template.`)); } return new CachedCloudAssembly(assembly); } /** * Diff Action */ public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise<{ [name: string]: TemplateDiff }> { const ioHelper = asIoHelper(this.ioHost, 'diff'); const selectStacks = options.stacks ?? ALL_STACKS; const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: selectStacks }); await using assembly = await assemblyFromSource(ioHelper, cx); const stacks = await assembly.selectStacksV2(selectStacks); await synthSpan.end(); const diffSpan = await ioHelper.span(SPAN.DIFF_STACK).begin({ stacks: selectStacks }); const deployments = await this.deploymentsForAction('diff'); const strict = !!options.strict; const contextLines = options.contextLines || 3; let diffs = 0; let formattedSecurityDiff = ''; let formattedStackDiff = ''; const templateInfos = await prepareDiff(ioHelper, stacks, deployments, await this.sdkProvider('diff'), options); const templateDiffs: { [name: string]: TemplateDiff } = {}; for (const templateInfo of templateInfos) { const formatter = new DiffFormatter({ ioHelper, templateInfo, }); if (options.securityOnly) { const securityDiff = formatter.formatSecurityDiff(); // In Diff, we only care about BROADENING security diffs if (securityDiff.permissionChangeType == PermissionChangeType.BROADENING) { const warningMessage = 'This deployment will make potentially sensitive changes according to your current security approval level.\nPlease confirm you intend to make the following modifications:\n'; await ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(warningMessage)); formattedSecurityDiff = securityDiff.formattedDiff; diffs = securityDiff.formattedDiff ? diffs + 1 : diffs; } } else { const diff = formatter.formatStackDiff({ strict, context: contextLines, }); formattedStackDiff = diff.formattedDiff; diffs = diff.numStacksWithChanges; } appendObject(templateDiffs, formatter.diffs); } await diffSpan.end(`✨ Number of stacks with differences: ${diffs}`, { formattedSecurityDiff, formattedStackDiff, }); return templateDiffs; } /** * List Action * * List selected stacks and their dependencies */ public async list(cx: ICloudAssemblySource, options: ListOptions = {}): Promise<StackDetails[]> { const ioHelper = asIoHelper(this.ioHost, 'list'); const selectStacks = options.stacks ?? ALL_STACKS; const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: selectStacks }); await using assembly = await assemblyFromSource(ioHelper, cx); const stackCollection = await assembly.selectStacksV2(selectStacks); await synthSpan.end(); const stacks = stackCollection.withDependencies(); const message = stacks.map(s => s.id).join('\n'); await ioHelper.notify(IO.CDK_TOOLKIT_I2901.msg(message, { stacks })); return stacks; } /** * Deploy Action * * Deploys the selected stacks into an AWS account */ public async deploy(cx: ICloudAssemblySource, options: DeployOptions = {}): Promise<DeployResult> { const ioHelper = asIoHelper(this.ioHost, 'deploy'); await using assembly = await assemblyFromSource(ioHelper, cx); return await this._deploy(assembly, 'deploy', options); } /** * Helper to allow deploy being called as part of the watch action. */ private async _deploy(assembly: StackAssembly, action: 'deploy' | 'watch', options: ExtendedDeployOptions = {}): Promise<DeployResult> { const ioHelper = asIoHelper(this.ioHost, action); const selectStacks = options.stacks ?? ALL_STACKS; const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: selectStacks }); const stackCollection = await assembly.selectStacksV2(selectStacks); await this.validateStacksMetadata(stackCollection, ioHelper); const synthDuration = await synthSpan.end(); const ret: DeployResult = { stacks: [], }; if (stackCollection.stackCount === 0) { await ioHelper.notify(IO.CDK_TOOLKIT_E5001.msg('This app contains no stacks')); return ret; } const deployments = await this.deploymentsForAction('deploy'); const migrator = new ResourceMigrator({ deployments, ioHelper }); await migrator.tryMigrateResources(stackCollection, options); const parameterMap = buildParameterMap(options.parameters?.parameters); const hotswapMode = options.hotswap ?? HotswapMode.FULL_DEPLOYMENT; if (hotswapMode !== HotswapMode.FULL_DEPLOYMENT) { await ioHelper.notify(IO.CDK_TOOLKIT_W5400.msg([ '⚠️ The --hotswap and --hotswap-fallback flags deliberately introduce CloudFormation drift to speed up deployments', '⚠️ They should only be used for development - never use them for your production Stacks!', ].join('\n'))); } const stacks = stackCollection.stackArtifacts; const stackOutputs: { [key: string]: any } = {}; const outputsFile = options.outputsFile; const buildAsset = async (assetNode: AssetBuildNode) => { const buildAssetSpan = await ioHelper.span(SPAN.BUILD_ASSET).begin({ asset: assetNode.asset, }); await deployments.buildSingleAsset( assetNode.assetManifestArtifact, assetNode.assetManifest, assetNode.asset, { stack: assetNode.parentStack, roleArn: options.roleArn, stackName: assetNode.parentStack.stackName, }, ); await buildAssetSpan.end(); }; const publishAsset = async (assetNode: AssetPublishNode) => { const publishAssetSpan = await ioHelper.span(SPAN.PUBLISH_ASSET).begin({ asset: assetNode.asset, }); await deployments.publishSingleAsset(assetNode.assetManifest, assetNode.asset, { stack: assetNode.parentStack, roleArn: options.roleArn, stackName: assetNode.parentStack.stackName, forcePublish: options.forceAssetPublishing, }); await publishAssetSpan.end(); }; const deployStack = async (stackNode: StackNode) => { const stack = stackNode.stack; if (stackCollection.stackCount !== 1) { await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(chalk.bold(stack.displayName))); } if (!stack.environment) { throw new ToolkitError( `Stack ${stack.displayName} does not define an environment, and AWS credentials could not be obtained from standard locations or no region was configured.`, ); } // The generated stack has no resources if (Object.keys(stack.template.Resources || {}).length === 0) { // stack is empty and doesn't exist => do nothing const stackExists = await deployments.stackExists({ stack }); if (!stackExists) { return ioHelper.notify(IO.CDK_TOOLKIT_W5021.msg(`${chalk.bold(stack.displayName)}: stack has no resources, skipping deployment.`)); } // stack is empty, but exists => delete await ioHelper.notify(IO.CDK_TOOLKIT_W5022.msg(`${chalk.bold(stack.displayName)}: stack has no resources, deleting existing stack.`)); await this._destroy(assembly, 'deploy', { stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE }, roleArn: options.roleArn, }); return; } const currentTemplate = await deployments.readCurrentTemplate(stack); const formatter = new DiffFormatter({ ioHelper, templateInfo: { oldTemplate: currentTemplate, newTemplate: stack, }, }); const securityDiff = formatter.formatSecurityDiff(); const permissionChangeType = securityDiff.permissionChangeType; const deployMotivation = '"--require-approval" is enabled and stack includes security-sensitive updates.'; const deployQuestion = `${deployMotivation}\nDo you wish to deploy these changes`; const deployConfirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5060.req(deployQuestion, { motivation: deployMotivation, concurrency, permissionChangeType, })); if (!deployConfirmed) { throw new ToolkitError('Aborted by user'); } // Following are the same semantics we apply with respect to Notification ARNs (dictated by the SDK) // // - undefined => cdk ignores it, as if it wasn't supported (allows external management). // - []: => cdk manages it, and the user wants to wipe it out. // - ['arn-1'] => cdk manages it, and the user wants to set it to ['arn-1']. const notificationArns = (!!options.notificationArns || !!stack.notificationArns) ? (options.notificationArns ?? []).concat(stack.notificationArns ?? []) : undefined; for (const notificationArn of notificationArns ?? []) { if (!validateSnsTopicArn(notificationArn)) { throw new ToolkitError(`Notification arn ${notificationArn} is not a valid arn for an SNS topic`); } } const stackIndex = stacks.indexOf(stack) + 1; const deploySpan = await ioHelper.span(SPAN.DEPLOY_STACK) .begin(`${chalk.bold(stack.displayName)}: deploying... [${stackIndex}/${stackCollection.stackCount}]`, { total: stackCollection.stackCount, current: stackIndex, stack, }); let tags = options.tags; if (!tags || tags.length === 0) { tags = tagsForStack(stack); } let deployDuration; try { let deployResult: SuccessfulDeployStackResult | undefined; let rollback = options.rollback; let iteration = 0; while (!deployResult) { if (++iteration > 2) { throw new ToolkitError('This loop should have stabilized in 2 iterations, but didn\'t. If you are seeing this error, please report it at https://github.com/aws/aws-cdk/issues/new/choose'); } const r = await deployments.deployStack({ stack, deployName: stack.stackName, roleArn: options.roleArn, toolkitStackName: this.toolkitStackName, reuseAssets: options.reuseAssets, notificationArns, tags, deploymentMethod: options.deploymentMethod, forceDeployment: options.forceDeployment, parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]), usePreviousParameters: options.parameters?.keepExistingParameters, rollback, hotswap: hotswapMode, extraUserAgent: options.extraUserAgent, hotswapPropertyOverrides: options.hotswapProperties ? createHotswapPropertyOverrides(options.hotswapProperties) : undefined, assetParallelism: options.assetParallelism, }); switch (r.type) { case 'did-deploy-stack': deployResult = r; break; case 'failpaused-need-rollback-first': { const motivation = r.reason === 'replacement' ? `Stack is in a paused fail state (${r.status}) and change includes a replacement which cannot be deployed with "--no-rollback"` : `Stack is in a paused fail state (${r.status}) and command line arguments do not include "--no-rollback"`; const question = `${motivation}. Perform a regular deployment`; const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5050.req(question, { motivation, concurrency, })); if (!confirmed) { throw new ToolkitError('Aborted by user'); } // Perform a rollback await this._rollback(assembly, action, { stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, }, orphanFailedResources: options.orphanFailedResourcesDuringRollback, }); // Go around through the 'while' loop again but switch rollback to true. rollback = true; break; } case 'replacement-requires-rollback': { const motivation = 'Change includes a replacement which cannot be deployed with "--no-rollback"'; const question = `${motivation}. Perform a regular deployment`; const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5050.req(question, { motivation, concurrency, })); if (!confirmed) { throw new ToolkitError('Aborted by user'); } // Go around through the 'while' loop again but switch rollback to true. rollback = true; break; } default: throw new ToolkitError(`Unexpected result type from deployStack: ${JSON.stringify(r)}. If you are seeing this error, please report it at https://github.com/aws/aws-cdk/issues/new/choose`); } } const message = deployResult.noOp ? ` ✅ ${stack.displayName} (no changes)` : ` ✅ ${stack.displayName}`; await ioHelper.notify(IO.CDK_TOOLKIT_I5900.msg(chalk.green('\n' + message), deployResult)); deployDuration = await deploySpan.timing(IO.CDK_TOOLKIT_I5000); if (Object.keys(deployResult.outputs).length > 0) { const buffer = ['Outputs:']; stackOutputs[stack.stackName] = deployResult.outputs; for (const name of Object.keys(deployResult.outputs).sort()) { const value = deployResult.outputs[name]; buffer.push(`${chalk.cyan(stack.id)}.${chalk.cyan(name)} = ${chalk.underline(chalk.cyan(value))}`); } await ioHelper.notify(IO.CDK_TOOLKIT_I5901.msg(buffer.join('\n'))); } await ioHelper.notify(IO.CDK_TOOLKIT_I5901.msg(`Stack ARN:\n${deployResult.stackArn}`)); ret.stacks.push({ stackName: stack.stackName, environment: { account: stack.environment.account, region: stack.environment.region, }, stackArn: deployResult.stackArn, outputs: deployResult.outputs, hierarchicalId: stack.hierarchicalId, }); } catch (e: any) { // It has to be exactly this string because an integration test tests for // "bold(stackname) failed: ResourceNotReady: <error>" throw new ToolkitError( [`❌ ${chalk.bold(stack.stackName)} failed:`, ...(e.name ? [`${e.name}:`] : []), e.message].join(' '), ); } finally { if (options.traceLogs) { // deploy calls that originate from watch will come with their own cloudWatchLogMonitor const cloudWatchLogMonitor = options.cloudWatchLogMonitor ?? new CloudWatchLogEventMonitor({ ioHelper }); const foundLogGroupsResult = await findCloudWatchLogGroups(await this.sdkProvider('deploy'), ioHelper, stack); cloudWatchLogMonitor.addLogGroups( foundLogGroupsResult.env, foundLogGroupsResult.sdk, foundLogGroupsResult.logGroupNames, ); await ioHelper.notify(IO.CDK_TOOLKIT_I5031.msg(`The following log groups are added: ${foundLogGroupsResult.logGroupNames}`)); } // If an outputs file has been specified, create the file path and write stack outputs to it once. // Outputs are written after all stacks have been deployed. If a stack deployment fails, // all of the outputs from successfully deployed stacks before the failure will still be written. if (outputsFile) { fs.ensureFileSync(outputsFile); await fs.writeJson(outputsFile, stackOutputs, { spaces: 2, encoding: 'utf8', }); } } const duration = synthDuration.asMs + (deployDuration?.asMs ?? 0); await deploySpan.end(`\n✨ Total time: ${formatTime(duration)}s\n`, { duration }); }; const assetBuildTime = options.assetBuildTime ?? AssetBuildTime.ALL_BEFORE_DEPLOY; const prebuildAssets = assetBuildTime === AssetBuildTime.ALL_BEFORE_DEPLOY; const concurrency = options.concurrency || 1; const stacksAndTheirAssetManifests = stacks.flatMap((stack) => [ stack, ...stack.dependencies.filter(x => cxapi.AssetManifestArtifact.isAssetManifestArtifact(x)), ]); const workGraph = new WorkGraphBuilder(ioHelper, prebuildAssets).build(stacksAndTheirAssetManifests); // Unless we are running with '--force', skip already published assets if (!options.forceAssetPublishing) { await removePublishedAssetsFromWorkGraph(workGraph, deployments, options); } const graphConcurrency: Concurrency = { 'stack': concurrency, 'asset-build': 1, // This will be CPU-bound/memory bound, mostly matters for Docker builds 'asset-publish': (options.assetParallelism ?? true) ? 8 : 1, // This will be I/O-bound, 8 in parallel seems reasonable }; await workGraph.doParallel(graphConcurrency, { deployStack, buildAsset, publishAsset, }); return ret; } /** * Watch Action * * Continuously observe project files and deploy the selected stacks * automatically when changes are detected. Implies hotswap deployments. * * This function returns immediately, starting a watcher in the background. */ public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise<IWatcher> { const ioHelper = asIoHelper(this.ioHost, 'watch'); await using assembly = await assemblyFromSource(ioHelper, cx, false); const rootDir = options.watchDir ?? process.cwd(); if (options.include === undefined && options.exclude === undefined) { throw new ToolkitError( "Cannot use the 'watch' command without specifying at least one directory to monitor. " + 'Make sure to add a "watch" key to your cdk.json', ); } // For the "include" subkey under the "watch" key, the behavior is: // 1. No "watch" setting? We error out. // 2. "watch" setting without an "include" key? We default to observing "./**". // 3. "watch" setting with an empty "include" key? We default to observing "./**". // 4. Non-empty "include" key? Just use the "include" key. const watchIncludes = patternsArrayForWatch(options.include, { rootDir, returnRootDirIfEmpty: true, }); // For the "exclude" subkey under the "watch" key, // the behavior is to add some default excludes in addition to the ones specified by the user: // 1. The CDK output directory. // 2. Any file whose name starts with a dot. // 3. Any directory's content whose name starts with a dot. // 4. Any node_modules and its content (even if it's not a JS/TS project, you might be using a local aws-cli package) const outdir = assembly.directory; const watchExcludes = patternsArrayForWatch(options.exclude, { rootDir, returnRootDirIfEmpty: false, }); // only exclude the outdir if it is under the rootDir const relativeOutDir = path.relative(rootDir, outdir); if (Boolean(relativeOutDir && !relativeOutDir.startsWith('..' + path.sep) && !path.isAbsolute(relativeOutDir))) { watchExcludes.push(`${relativeOutDir}/**`); } watchExcludes.push('**/.*', '**/.*/**', '**/node_modules/**'); // Print some debug information on computed settings await ioHelper.notify(IO.CDK_TOOLKIT_I5310.msg([ `root directory used for 'watch' is: ${rootDir}`, `'include' patterns for 'watch': ${JSON.stringify(watchIncludes)}`, `'exclude' patterns for 'watch': ${JSON.stringify(watchExcludes)}`, ].join('\n'), { watchDir: rootDir, includes: watchIncludes, excludes: watchExcludes, })); // Since 'cdk deploy' is a relatively slow operation for a 'watch' process, // introduce a concurrency latch that tracks the state. // This way, if file change events arrive when a 'cdk deploy' is still executing, // we will batch them, and trigger another 'cdk deploy' after the current one finishes, // making sure 'cdk deploy's always execute one at a time. // Here's a diagram showing the state transitions: // -------------- -------- file changed -------------- file changed -------------- file changed // | | ready event | | ------------------> | | ------------------> | | --------------| // | pre-ready | -------------> | open | | deploying | | queued | | // | | | | <------------------ | | <------------------ | | <-------------| // -------------- -------- 'cdk deploy' done -------------- 'cdk deploy' done -------------- type LatchState = 'pre-ready' | 'open' | 'deploying' | 'queued'; let latch: LatchState = 'pre-ready'; const cloudWatchLogMonitor = options.traceLogs ? new CloudWatchLogEventMonitor({ ioHelper }) : undefined; const deployAndWatch = async () => { latch = 'deploying' as LatchState; await cloudWatchLogMonitor?.deactivate(); await this.invokeDeployFromWatch(assembly, options, cloudWatchLogMonitor); // If latch is still 'deploying' after the 'await', that's fine, // but if it's 'queued', that means we need to deploy again while (latch === 'queued') { // TypeScript doesn't realize latch can change between 'awaits', // and thinks the above 'while' condition is always 'false' without the cast latch = 'deploying'; await ioHelper.notify(IO.CDK_TOOLKIT_I5315.msg("Detected file changes during deployment. Invoking 'cdk deploy' again")); await this.invokeDeployFromWatch(assembly, options, cloudWatchLogMonitor); } latch = 'open'; await cloudWatchLogMonitor?.activate(); }; const watcher = chokidar .watch(watchIncludes, { ignored: watchExcludes, cwd: rootDir, }) .on('ready', async () => { latch = 'open'; await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg("'watch' received the 'ready' event. From now on, all file changes will trigger a deployment")); await ioHelper.notify(IO.CDK_TOOLKIT_I5314.msg("Triggering initial 'cdk deploy'")); await deployAndWatch(); }) .on('all', async (event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', filePath: string) => { const watchEvent = { event, path: filePath, }; if (latch === 'pre-ready') { await ioHelper.notify(IO.CDK_TOOLKIT_I5311.msg(`'watch' is observing ${event === 'addDir' ? 'directory' : 'the file'} '${filePath}' for changes`, watchEvent)); } else if (latch === 'open') { await ioHelper.notify(IO.CDK_TOOLKIT_I5312.msg(`Detected change to '${filePath}' (type: ${event}). Triggering 'cdk deploy'`, watchEvent)); await deployAndWatch(); } else { // this means latch is either 'deploying' or 'queued' latch = 'queued'; await ioHelper.notify(IO.CDK_TOOLKIT_I5313.msg( `Detected change to '${filePath}' (type: ${event}) while 'cdk deploy' is still running. Will queue for another deployment after this one finishes'`, watchEvent, )); } }); const stoppedPromise = promiseWithResolvers<void>(); return { async dispose() { await watcher.close(); // Prevents Node from staying alive. There is no 'end' event that the watcher emits // that we can know it's definitely done, so best we can do is tell it to stop watching, // stop keeping Node alive, and then pretend that's everything we needed to do. watcher.unref(); stoppedPromise.resolve(); return stoppedPromise.promise; }, async waitForEnd() { return stoppedPromise.promise; }, async [Symbol.asyncDispose]() { return this.dispose(); }, } satisfies IWatcher; } /** * Rollback Action * * Rolls back the selected stacks. */ public async rollback(cx: ICloudAssemblySource, options: RollbackOptions): Promise<RollbackResult> { const ioHelper = asIoHelper(this.ioHost, 'rollback'); await using assembly = await assemblyFromSource(ioHelper, cx); return await this._rollback(assembly, 'rollback', options); } /** * Helper to allow rollback being called as part of the deploy or watch action. */ private async _rollback(assembly: StackAssembly, action: 'rollback' | 'deploy' | 'watch', options: RollbackOptions): Promise<RollbackResult> { const ioHelper = asIoHelper(this.ioHost, action); const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: options.stacks }); const stacks = await assembly.selectStacksV2(options.stacks); await this.validateStacksMetadata(stacks, ioHelper); await synthSpan.end(); const ret: RollbackResult = { stacks: [], }; if (stacks.stackCount === 0) { await ioHelper.notify(IO.CDK_TOOLKIT_E6001.msg('No stacks selected')); return ret; } let anyRollbackable = false; for (const [index, stack] of stacks.stackArtifacts.entries()) { const rollbackSpan = await ioHelper.span(SPAN.ROLLBACK_STACK).begin(`Rolling back ${chalk.bold(stack.displayName)}`, { total: stacks.stackCount, current: index + 1, stack, }); const deployments = await this.deploymentsForAction('rollback'); try { const stackResult = await deployments.rollbackStack({ stack, roleArn: options.roleArn, toolkitStackName: this.toolkitStackName, orphanFailedResources: options.orphanFailedResources, validateBootstrapStackVersion: options.validateBootstrapStackVersion, orphanLogicalIds: options.orphanLogicalIds, }); if (!stackResult.notInRollbackableState) { anyRollbackable = true; } await rollbackSpan.end(); ret.stacks.push({ environment: { account: stack.environment.account, region: stack.environment.region, }, stackName: stack.stackName, stackArn: stackResult.stackArn, result: stackResult.notInRollbackableState ? 'already-stable' : 'rolled-back', }); } catch (e: any) { await ioHelper.notify(IO.CDK_TOOLKIT_E6900.msg(`\n ❌ ${chalk.bold(stack.displayName)} failed: ${formatErrorMessage(e)}`, { error: e })); throw ToolkitError.withCause('Rollback failed (use --force to orphan failing resources)', e); } } if (!anyRollbackable) { throw new ToolkitError('No stacks were in a state that could be rolled back'); } return ret; } /** * Refactor Action. Moves resources from one location (stack + logical ID) to another. */ public async refactor(cx: ICloudAssemblySource, options: RefactorOptions = {}): Promise<void> { const ioHelper = asIoHelper(this.ioHost, 'refactor'); const assembly = await assemblyFromSource(ioHelper, cx); return this._refactor(assembly, ioHelper, options); } private async _refactor(assembly: StackAssembly, ioHelper: IoHelper, options: RefactorOptions = {}): Promise<void> { if (!options.dryRun) { throw new ToolkitError('Refactor is not available yet. Too see the proposed changes, use the --dry-run flag.'); } const stacks = await assembly.selectStacksV2(ALL_STACKS); const sdkProvider = await this.sdkProvider('refactor'); const exclude = fromManifestAndExclusionList(assembly.cloudAssembly.manifest, options.exclude); const movements = await findResourceMovements(stacks.stackArtifacts, sdkProvider, exclude); const ambiguous = ambiguousMovements(movements); if (ambiguous.length === 0) { const filteredStacks = await assembly.selectStacksV2(options.stacks ?? ALL_STACKS); const mappings = resourceMappings(movements, filteredStacks.stackArtifacts); const typedMappings = mappings.map(m => m.toTypedMapping()); await ioHelper.notify(IO.CDK_TOOLKIT_I8900.msg(formatTypedMappings(typedMappings), { typedMappings, })); } else { const error = new AmbiguityError(ambiguous); const paths = error.paths(); await ioHelper.notify(IO.CDK_TOOLKIT_I8900.msg(formatAmbiguousMappings(paths), { ambiguousPaths: paths, })); } } /** * Destroy Action * * Destroys the selected Stacks. */ public async destroy(cx: ICloudAssemblySource, options: DestroyOptions): Promise<DestroyResult> { const ioHelper = asIoHelper(this.ioHost, 'destroy'); await using assembly = await assemblyFromSource(ioHelper, cx); return await this._destroy(assembly, 'destroy', options); } /** * Helper to allow destroy being called as part of the deploy action. */ private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise<DestroyResult> { const ioHelper = asIoHelper(this.ioHost, action); const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: options.stacks }); // The stacks will have been ordered for deployment, so reverse them for deletion. const stacks = (await assembly.selectStacksV2(options.stacks)).reversed(); await synthSpan.end(); const ret: DestroyResult = { stacks: [], }; const motivation = 'Destroying stacks is an irreversible action'; const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); if (!confirmed) { await ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg('Aborted by user')); return ret; } const destroySpan = await ioHelper.span(SPAN.DESTROY_ACTION).begin({ stacks: stacks.stackArtifacts, }); try { for (const [index, stack] of stacks.stackArtifacts.entries()) { try { const singleDestroySpan = await ioHelper.span(SPAN.DESTROY_STACK) .begin(chalk.green(`${chalk.blue(stack.displayName)}: destroying... [${index + 1}/${stacks.stackCount}]`), { total: stacks.stackCount, current: index + 1, stack, }); const deployments = await this.deploymentsForAction(action); const result = await deployments.destroyStack({ stack, deployName: stack.stackName, roleArn: options.roleArn, }); ret.stacks.push({ environment: { account: stack.environment.account, region: stack.environment.region, }, stackName: stack.stackName, stackArn: result.stackArn, stackExisted: result.stackArn !== undefined, }); await ioHelper.notify(IO.CDK_TOOLKIT_I7900.msg(chalk.green(`\n ✅ ${chalk.blue(stack.displayName)}: ${action}ed`), stack)); await singleDestroySpan.end(); } catch (e: any) { await ioHelper.notify(IO.CDK_TOOLKIT_E7900.msg(`\n ❌ ${chalk.blue(stack.displayName)}: ${action} failed ${e}`, { error: e })); throw e; } } return ret; } finally { await destroySpan.end(); } } /** * Validate the stacks for errors and warnings according to the CLI's current settings */ private async validateStacksMetadata(stacks: StackCollection, ioHost: IoHelper) { const builder = (level: IoMessageLevel) => { switch (level) { case 'error': return IO.CDK_ASSEMBLY_E9999; case 'warn': return IO.CDK_ASSEMBLY_W9999; default: return IO.CDK_ASSEMBLY_I9999; } }; await stacks.validateMetadata( this.props.assemblyFailureAt, async (level, msg) => ioHost.notify(builder(level).msg(`[${level} at ${msg.id}] ${msg.entry.data}`, msg)), ); } /** * Create a deployments class */ private async deploymentsForAction(action: ToolkitAction): Promise<Deployments> { return new Deployments({ sdkProvider: await this.sdkProvider(action), toolkitStackName: this.toolkitStackName, ioHelper: asIoHelper(this.ioHost, action), }); } private async invokeDeployFromWatch( assembly: StackAssembly, options: WatchOptions, cloudWatchLogMonitor?: CloudWatchLogEventMonitor, ): Promise<void> { // watch defaults hotswap to enabled const hotswap = options.hotswap ?? HotswapMode.HOTSWAP_ONLY; const deployOptions: ExtendedDeployOptions = { ...options, cloudWatchLogMonitor, hotswap, extraUserAgent: `cdk-watch/hotswap-${hotswap === HotswapMode.FULL_DEPLOYMENT ? 'off' : 'on'}`, }; try { await this._deploy(assembly, 'watch', deployOptions); } catch { // just continue - deploy will show the error } } } /** * The result of a `cdk.watch()` operation. */ export interface IWatcher extends AsyncDisposable { /** * Stop the watcher and wait for the current watch iteration to complete. * * An alias for `[Symbol.asyncDispose]`, as a more readable alternative for * environments that don't support the Disposable APIs yet. */ dispose(): Promise<void>; /** * Wait for the watcher to stop. * * The watcher will only stop if `dispose()` or `[Symbol.asyncDispose]()` are called. * * If neither of those is called, awaiting this promise will wait forever. */ waitForEnd(): Promise<void>; }