diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap.ts index b37394e75..462a84b18 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap.ts @@ -1,4 +1,30 @@ import type { PropertyDifference, Resource } from '@aws-cdk/cloudformation-diff'; +import type * as cxapi from '@aws-cdk/cx-api'; + +/** + * A resource affected by a change + */ +export interface AffectedResource { + /** + * The logical ID of the affected resource in the template + */ + readonly logicalId: string; + /** + * The CloudFormation type of the resource + * This could be a custom type. + */ + readonly resourceType: string; + /** + * The friendly description of the affected resource + */ + readonly description?: string; + /** + * The physical name of the resource when deployed. + * + * A physical name is not always available, e.g. new resources will not have one until after the deployment + */ + readonly physicalName?: string; +} /** * Represents a change in a resource @@ -22,9 +48,28 @@ export interface ResourceChange { readonly propertyUpdates: Record>; } +/** + * A change that can be hotswapped + */ export interface HotswappableChange { /** * The resource change that is causing the hotswap. */ readonly cause: ResourceChange; } + +/** + * Information about a hotswap deployment + */ +export interface HotswapDeployment { + /** + * The stack that's currently being deployed + */ + readonly stack: cxapi.CloudFormationStackArtifact; + + /** + * The mode the hotswap deployment was initiated with. + */ + readonly mode: 'hotswap-only' | 'fall-back'; +} + diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/private/messages.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/private/messages.ts index cde7203cd..92a6486be 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/private/messages.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/private/messages.ts @@ -5,6 +5,7 @@ import type { BootstrapEnvironmentProgress } from '../payloads/bootstrap-environ import type { MissingContext, UpdatedContext } from '../payloads/context'; import type { BuildAsset, DeployConfirmationRequest, PublishAsset, StackDeployProgress, SuccessfulDeployStackResult } from '../payloads/deploy'; import type { StackDestroy, StackDestroyProgress } from '../payloads/destroy'; +import type { HotswapDeployment } from '../payloads/hotswap'; import type { StackDetailsPayload } from '../payloads/list'; import type { CloudWatchLogEvent, CloudWatchLogMonitorControlEvent } from '../payloads/logs-monitor'; import type { StackRollbackProgress } from '../payloads/rollback'; @@ -188,6 +189,16 @@ export const IO = { }), // Hotswap (54xx) + CDK_TOOLKIT_I5400: make.trace({ + code: 'CDK_TOOLKIT_I5400', + description: 'Starting a hotswap deployment', + interface: 'HotswapDeployment', + }), + CDK_TOOLKIT_I5410: make.info({ + code: 'CDK_TOOLKIT_I5410', + description: 'Hotswap deployment has ended, a full deployment might still follow if needed', + interface: 'Duration', + }), // Stack Monitor (55xx) CDK_TOOLKIT_I5501: make.info({ @@ -443,4 +454,9 @@ export const SPAN = { start: IO.CDK_TOOLKIT_I5220, end: IO.CDK_TOOLKIT_I5221, }, + HOTSWAP: { + name: 'hotswap-deployment', + start: IO.CDK_TOOLKIT_I5400, + end: IO.CDK_TOOLKIT_I5410, + }, } satisfies Record>; diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index 4c0f070a8..b3482a759 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -38,6 +38,8 @@ group: Documents | `CDK_TOOLKIT_I5313` | File event detected during active deployment, changes are queued | `info` | {@link FileWatchEvent} | | `CDK_TOOLKIT_I5314` | Initial watch deployment started | `info` | n/a | | `CDK_TOOLKIT_I5315` | Queued watch deployment started | `info` | n/a | +| `CDK_TOOLKIT_I5400` | Starting a hotswap deployment | `trace` | {@link HotswapDeployment} | +| `CDK_TOOLKIT_I5410` | Hotswap deployment has ended, a full deployment might still follow if needed | `info` | {@link Duration} | | `CDK_TOOLKIT_I5501` | Stack Monitoring: Start monitoring of a single stack | `info` | {@link StackMonitoringControlEvent} | | `CDK_TOOLKIT_I5502` | Stack Monitoring: Activity event for a single stack | `info` | {@link StackActivity} | | `CDK_TOOLKIT_I5503` | Stack Monitoring: Finished monitoring of a single stack | `info` | {@link StackMonitoringControlEvent} | diff --git a/packages/aws-cdk/lib/api/deployments/deploy-stack.ts b/packages/aws-cdk/lib/api/deployments/deploy-stack.ts index 82efdac92..1ecd99c82 100644 --- a/packages/aws-cdk/lib/api/deployments/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deployments/deploy-stack.ts @@ -30,8 +30,7 @@ import { import type { ChangeSetDeploymentMethod, DeploymentMethod } from './deployment-method'; import type { DeployStackResult, SuccessfulDeployStackResult } from './deployment-result'; import { tryHotswapDeployment } from './hotswap-deployments'; -import type { IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; -import { debug, info, warn } from '../../cli/messages'; +import { IO, type IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; import { ToolkitError } from '../../toolkit/error'; import { formatErrorMessage } from '../../util'; import type { SDK, SdkProvider, ICloudFormationClient } from '../aws-auth'; @@ -214,7 +213,7 @@ export async function deployStack(options: DeployStackOptions, ioHelper: IoHelpe let cloudFormationStack = await CloudFormationStack.lookup(cfn, deployName); if (cloudFormationStack.stackStatus.isCreationFailure) { - await ioHelper.notify(debug( + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg( `Found existing stack ${deployName} that had previously failed creation. Deleting it before attempting to re-create it.`, )); await cfn.deleteStack({ StackName: deployName }); @@ -253,11 +252,11 @@ export async function deployStack(options: DeployStackOptions, ioHelper: IoHelpe const hotswapPropertyOverrides = options.hotswapPropertyOverrides ?? new HotswapPropertyOverrides(); if (await canSkipDeploy(options, cloudFormationStack, stackParams.hasChanges(cloudFormationStack.parameters), ioHelper)) { - await ioHelper.notify(debug(`${deployName}: skipping deployment (use --force to override)`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: skipping deployment (use --force to override)`)); // if we can skip deployment and we are performing a hotswap, let the user know // that no hotswap deployment happened if (hotswapMode !== HotswapMode.FULL_DEPLOYMENT) { - await ioHelper.notify(info( + await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg( format( `\n ${ICON} %s\n`, chalk.bold('hotswap deployment skipped - no changes were detected (use --force to override)'), @@ -271,7 +270,7 @@ export async function deployStack(options: DeployStackOptions, ioHelper: IoHelpe stackArn: cloudFormationStack.stackId, }; } else { - await ioHelper.notify(debug(`${deployName}: deploying...`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: deploying...`)); } const bodyParameter = await makeBodyParameter( @@ -285,7 +284,7 @@ export async function deployStack(options: DeployStackOptions, ioHelper: IoHelpe try { bootstrapStackName = (await options.envResources.lookupToolkit()).stackName; } catch (e) { - await ioHelper.notify(debug(`Could not determine the bootstrap stack name: ${e}`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Could not determine the bootstrap stack name: ${e}`)); } await publishAssets(legacyAssets.toManifest(stackArtifact.assembly.directory), options.sdkProvider, stackEnv, { parallel: options.assetParallelism, @@ -301,12 +300,15 @@ export async function deployStack(options: DeployStackOptions, ioHelper: IoHelpe stackParams.values, cloudFormationStack, stackArtifact, - hotswapMode, hotswapPropertyOverrides, + hotswapMode, + hotswapPropertyOverrides, ); + if (hotswapDeploymentResult) { return hotswapDeploymentResult; } - await ioHelper.notify(info(format( + + await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format( 'Could not perform a hotswap deployment, as the stack %s contains non-Asset changes', stackArtifact.displayName, ))); @@ -314,14 +316,14 @@ export async function deployStack(options: DeployStackOptions, ioHelper: IoHelpe if (!(e instanceof CfnEvaluationException)) { throw e; } - await ioHelper.notify(info(format( + await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format( 'Could not perform a hotswap deployment, because the CloudFormation template could not be resolved: %s', formatErrorMessage(e), ))); } if (hotswapMode === HotswapMode.FALL_BACK) { - await ioHelper.notify(info('Falling back to doing a full deployment')); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg('Falling back to doing a full deployment')); options.sdk.appendCustomUserAgent('cdk-hotswap/fallback'); } else { return { @@ -404,9 +406,9 @@ class FullCloudFormationDeployment { await this.updateTerminationProtection(); if (changeSetHasNoChanges(changeSetDescription)) { - await this.ioHelper.notify(debug(format('No changes are to be performed on %s.', this.stackName))); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('No changes are to be performed on %s.', this.stackName))); if (execute) { - await this.ioHelper.notify(debug(format('Deleting empty change set %s', changeSetDescription.ChangeSetId))); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Deleting empty change set %s', changeSetDescription.ChangeSetId))); await this.cfn.deleteChangeSet({ StackName: this.stackName, ChangeSetName: changeSetName, @@ -414,7 +416,7 @@ class FullCloudFormationDeployment { } if (this.options.force) { - await this.ioHelper.notify(warn( + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg( [ 'You used the --force flag, but CloudFormation reported that the deployment would not make any changes.', 'According to CloudFormation, all resources are already up-to-date with the state in your CDK app.', @@ -434,7 +436,7 @@ class FullCloudFormationDeployment { } if (!execute) { - await this.ioHelper.notify(info(format( + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format( 'Changeset %s created and waiting in review for manual execution (--no-execute)', changeSetDescription.ChangeSetId, ))); @@ -466,8 +468,8 @@ class FullCloudFormationDeployment { private async createChangeSet(changeSetName: string, willExecute: boolean, importExistingResources: boolean) { await this.cleanupOldChangeset(changeSetName); - await this.ioHelper.notify(debug(`Attempting to create ChangeSet with name ${changeSetName} to ${this.verb} stack ${this.stackName}`)); - await this.ioHelper.notify(info(format('%s: creating CloudFormation changeset...', chalk.bold(this.stackName)))); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Attempting to create ChangeSet with name ${changeSetName} to ${this.verb} stack ${this.stackName}`)); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format('%s: creating CloudFormation changeset...', chalk.bold(this.stackName)))); const changeSet = await this.cfn.createChangeSet({ StackName: this.stackName, ChangeSetName: changeSetName, @@ -479,7 +481,7 @@ class FullCloudFormationDeployment { ...this.commonPrepareOptions(), }); - await this.ioHelper.notify(debug(format('Initiated creation of changeset: %s; waiting for it to finish creating...', changeSet.Id))); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Initiated creation of changeset: %s; waiting for it to finish creating...', changeSet.Id))); // Fetching all pages if we'll execute, so we can have the correct change count when monitoring. return waitForChangeSet(this.cfn, this.ioHelper, this.stackName, changeSetName, { fetchAll: willExecute, @@ -487,7 +489,7 @@ class FullCloudFormationDeployment { } private async executeChangeSet(changeSet: DescribeChangeSetCommandOutput): Promise { - await this.ioHelper.notify(debug(format('Initiating execution of changeset %s on stack %s', changeSet.ChangeSetId, this.stackName))); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Initiating execution of changeset %s on stack %s', changeSet.ChangeSetId, this.stackName))); await this.cfn.executeChangeSet({ StackName: this.stackName, @@ -496,7 +498,7 @@ class FullCloudFormationDeployment { ...this.commonExecuteOptions(), }); - await this.ioHelper.notify(debug( + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg( format( 'Execution of changeset %s on stack %s has started; waiting for the update to complete...', changeSet.ChangeSetId, @@ -513,7 +515,7 @@ class FullCloudFormationDeployment { if (this.cloudFormationStack.exists) { // Delete any existing change sets generated by CDK since change set names must be unique. // The delete request is successful as long as the stack exists (even if the change set does not exist). - await this.ioHelper.notify(debug(`Removing existing change set with name ${changeSetName} if it exists`)); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Removing existing change set with name ${changeSetName} if it exists`)); await this.cfn.deleteChangeSet({ StackName: this.stackName, ChangeSetName: changeSetName, @@ -525,7 +527,7 @@ class FullCloudFormationDeployment { // Update termination protection only if it has changed. const terminationProtection = this.stackArtifact.terminationProtection ?? false; if (!!this.cloudFormationStack.terminationProtection !== terminationProtection) { - await this.ioHelper.notify(debug( + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg( format ( 'Updating termination protection from %s to %s for stack %s', this.cloudFormationStack.terminationProtection, @@ -537,12 +539,12 @@ class FullCloudFormationDeployment { StackName: this.stackName, EnableTerminationProtection: terminationProtection, }); - await this.ioHelper.notify(debug(format('Termination protection updated to %s for stack %s', terminationProtection, this.stackName))); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Termination protection updated to %s for stack %s', terminationProtection, this.stackName))); } } private async directDeployment(): Promise { - await this.ioHelper.notify(info(format('%s: %s stack...', chalk.bold(this.stackName), this.update ? 'updating' : 'creating'))); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format('%s: %s stack...', chalk.bold(this.stackName), this.update ? 'updating' : 'creating'))); const startTime = new Date(); @@ -558,7 +560,7 @@ class FullCloudFormationDeployment { }); } catch (err: any) { if (err.message === 'No updates are to be performed.') { - await this.ioHelper.notify(debug(format('No updates are to be performed for stack %s', this.stackName))); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('No updates are to be performed for stack %s', this.stackName))); return { type: 'did-deploy-stack', noOp: true, @@ -611,7 +613,7 @@ class FullCloudFormationDeployment { } finally { await monitor.stop(); } - debug(format('Stack %s has completed updating', this.stackName)); + await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Stack %s has completed updating', this.stackName))); return { type: 'did-deploy-stack', noOp: false, @@ -709,11 +711,11 @@ async function canSkipDeploy( ioHelper: IoHelper, ): Promise { const deployName = deployStackOptions.deployName || deployStackOptions.stack.stackName; - await ioHelper.notify(debug(`${deployName}: checking if we can skip deploy`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: checking if we can skip deploy`)); // Forced deploy if (deployStackOptions.force) { - await ioHelper.notify(debug(`${deployName}: forced deployment`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: forced deployment`)); return false; } @@ -722,53 +724,53 @@ async function canSkipDeploy( deployStackOptions.deploymentMethod?.method === 'change-set' && deployStackOptions.deploymentMethod.execute === false ) { - await ioHelper.notify(debug(`${deployName}: --no-execute, always creating change set`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: --no-execute, always creating change set`)); return false; } // No existing stack if (!cloudFormationStack.exists) { - await ioHelper.notify(debug(`${deployName}: no existing stack`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: no existing stack`)); return false; } // Template has changed (assets taken into account here) if (JSON.stringify(deployStackOptions.stack.template) !== JSON.stringify(await cloudFormationStack.template())) { - await ioHelper.notify(debug(`${deployName}: template has changed`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: template has changed`)); return false; } // Tags have changed if (!compareTags(cloudFormationStack.tags, deployStackOptions.tags ?? [])) { - await ioHelper.notify(debug(`${deployName}: tags have changed`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: tags have changed`)); return false; } // Notification arns have changed if (!arrayEquals(cloudFormationStack.notificationArns, deployStackOptions.notificationArns ?? [])) { - await ioHelper.notify(debug(`${deployName}: notification arns have changed`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: notification arns have changed`)); return false; } // Termination protection has been updated if (!!deployStackOptions.stack.terminationProtection !== !!cloudFormationStack.terminationProtection) { - await ioHelper.notify(debug(`${deployName}: termination protection has been updated`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: termination protection has been updated`)); return false; } // Parameters have changed if (parameterChanges) { if (parameterChanges === 'ssm') { - await ioHelper.notify(debug(`${deployName}: some parameters come from SSM so we have to assume they may have changed`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: some parameters come from SSM so we have to assume they may have changed`)); } else { - await ioHelper.notify(debug(`${deployName}: parameters have changed`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: parameters have changed`)); } return false; } // Existing stack is in a failed state if (cloudFormationStack.stackStatus.isFailure) { - await ioHelper.notify(debug(`${deployName}: stack is in a failure state`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: stack is in a failure state`)); return false; } diff --git a/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts b/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts index 64ff01981..4d476b5e9 100644 --- a/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts @@ -4,12 +4,12 @@ import type * as cxapi from '@aws-cdk/cx-api'; import type { WaiterResult } from '@smithy/util-waiter'; import * as chalk from 'chalk'; import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads'; -import type { IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; +import type { IMessageSpan, IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; +import { IO, SPAN } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; import type { SDK, SdkProvider } from '../aws-auth'; import type { CloudFormationStack } from './cloudformation'; import type { NestedStackTemplates } from './nested-stack-helpers'; import { loadCurrentTemplateWithNestedStacks } from './nested-stack-helpers'; -import { info } from '../../cli/messages'; import { ToolkitError } from '../../toolkit/error'; import { formatErrorMessage } from '../../util'; import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; @@ -21,10 +21,10 @@ import type { NonHotswappableChange, HotswapPropertyOverrides, ClassifiedResourceChanges, + HotswapResult, } from '../hotswap/common'; import { ICON, - HotswapMode, reportNonHotswappableChange, reportNonHotswappableResource, } from '../hotswap/common'; @@ -49,6 +49,8 @@ type HotswapDetector = ( hotswapPropertyOverrides: HotswapPropertyOverrides, ) => Promise; +type HotswapMode = 'hotswap-only' | 'fall-back'; + const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { // Lambda 'AWS::Lambda::Function': isHotswappableLambdaFunctionChange, @@ -93,18 +95,59 @@ export async function tryHotswapDeployment( assetParams: { [key: string]: string }, cloudFormationStack: CloudFormationStack, stackArtifact: cxapi.CloudFormationStackArtifact, - hotswapMode: HotswapMode, hotswapPropertyOverrides: HotswapPropertyOverrides, + hotswapMode: HotswapMode, + hotswapPropertyOverrides: HotswapPropertyOverrides, ): Promise { + const hotswapSpan = await ioHelper.span(SPAN.HOTSWAP).begin({ + stack: stackArtifact, + mode: hotswapMode, + }); + + const result = await hotswapDeployment( + sdkProvider, + hotswapSpan, + assetParams, + stackArtifact, + hotswapMode, + hotswapPropertyOverrides, + ); + + await hotswapSpan.end(); + + if (result?.hotswapped === true) { + return { + type: 'did-deploy-stack', + noOp: result.hotswappableChanges.length === 0, + stackArn: cloudFormationStack.stackId, + outputs: cloudFormationStack.outputs, + }; + } + + return undefined; +} + +/** + * Perform a hotswap deployment, short-circuiting CloudFormation if possible. + * Returns information about the attempted hotswap deployment + */ +async function hotswapDeployment( + sdkProvider: SdkProvider, + ioSpan: IMessageSpan, + assetParams: { [key: string]: string }, + stack: cxapi.CloudFormationStackArtifact, + hotswapMode: HotswapMode, + hotswapPropertyOverrides: HotswapPropertyOverrides, +): Promise { // resolve the environment, so we can substitute things like AWS::Region in CFN expressions - const resolvedEnv = await sdkProvider.resolveEnvironment(stackArtifact.environment); + const resolvedEnv = await sdkProvider.resolveEnvironment(stack.environment); // create a new SDK using the CLI credentials, because the default one will not work for new-style synthesis - // it assumes the bootstrap deploy Role, which doesn't have permissions to update Lambda functions const sdk = (await sdkProvider.forEnvironment(resolvedEnv, Mode.ForWriting)).sdk; - const currentTemplate = await loadCurrentTemplateWithNestedStacks(stackArtifact, sdk); + const currentTemplate = await loadCurrentTemplateWithNestedStacks(stack, sdk); const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ - stackArtifact, + stackArtifact: stack, parameters: assetParams, account: resolvedEnv.account, region: resolvedEnv.region, @@ -113,7 +156,7 @@ export async function tryHotswapDeployment( nestedStacks: currentTemplate.nestedStacks, }); - const stackChanges = cfn_diff.fullDiff(currentTemplate.deployedRootTemplate, stackArtifact.template); + const stackChanges = cfn_diff.fullDiff(currentTemplate.deployedRootTemplate, stack.template); const { hotswappableChanges, nonHotswappableChanges } = await classifyResourceChanges( stackChanges, evaluateCfnTemplate, @@ -121,23 +164,28 @@ export async function tryHotswapDeployment( currentTemplate.nestedStacks, hotswapPropertyOverrides, ); - await logNonHotswappableChanges(ioHelper, nonHotswappableChanges, hotswapMode); + await logNonHotswappableChanges(ioSpan, nonHotswappableChanges, hotswapMode); // preserve classic hotswap behavior - if (hotswapMode === HotswapMode.FALL_BACK) { + if (hotswapMode === 'fall-back') { if (nonHotswappableChanges.length > 0) { - return undefined; + return { + stack, + hotswapped: false, + hotswappableChanges, + nonHotswappableChanges, + }; } } // apply the short-circuitable changes - await applyAllHotswappableChanges(sdk, ioHelper, hotswappableChanges); + await applyAllHotswappableChanges(sdk, ioSpan, hotswappableChanges); return { - type: 'did-deploy-stack', - noOp: hotswappableChanges.length === 0, - stackArn: cloudFormationStack.stackId, - outputs: cloudFormationStack.outputs, + stack, + hotswapped: true, + hotswappableChanges, + nonHotswappableChanges, }; } @@ -406,24 +454,24 @@ function isCandidateForHotswapping( }; } -async function applyAllHotswappableChanges(sdk: SDK, ioHelper: IoHelper, hotswappableChanges: HotswapOperation[]): Promise { +async function applyAllHotswappableChanges(sdk: SDK, ioSpan: IMessageSpan, hotswappableChanges: HotswapOperation[]): Promise { if (hotswappableChanges.length > 0) { - await ioHelper.notify(info(`\n${ICON} hotswapping resources:`)); + await ioSpan.notify(IO.DEFAULT_TOOLKIT_INFO.msg(`\n${ICON} hotswapping resources:`)); } const limit = pLimit(10); // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism return Promise.all(hotswappableChanges.map(hotswapOperation => limit(() => { - return applyHotswappableChange(sdk, ioHelper, hotswapOperation); + return applyHotswappableChange(sdk, ioSpan, hotswapOperation); }))); } -async function applyHotswappableChange(sdk: SDK, ioHelper: IoHelper, hotswapOperation: HotswapOperation): Promise { +async function applyHotswappableChange(sdk: SDK, ioSpan: IMessageSpan, hotswapOperation: HotswapOperation): Promise { // note the type of service that was successfully hotswapped in the User-Agent const customUserAgent = `cdk-hotswap/success-${hotswapOperation.service}`; sdk.appendCustomUserAgent(customUserAgent); for (const name of hotswapOperation.resourceNames) { - await ioHelper.notify(info(format(` ${ICON} %s`, chalk.bold(name)))); + await ioSpan.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format(` ${ICON} %s`, chalk.bold(name)))); } // if the SDK call fails, an error will be thrown by the SDK @@ -441,7 +489,7 @@ async function applyHotswappableChange(sdk: SDK, ioHelper: IoHelper, hotswapOper } for (const name of hotswapOperation.resourceNames) { - await ioHelper.notify(info(format(`${ICON} %s %s`, chalk.bold(name), chalk.green('hotswapped!')))); + await ioSpan.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format(`${ICON} %s %s`, chalk.bold(name), chalk.green('hotswapped!')))); } sdk.removeCustomUserAgent(customUserAgent); @@ -466,7 +514,7 @@ function formatWaiterErrorResult(result: WaiterResult) { } async function logNonHotswappableChanges( - ioHelper: IoHelper, + ioSpan: IMessageSpan, nonHotswappableChanges: NonHotswappableChange[], hotswapMode: HotswapMode, ): Promise { @@ -480,7 +528,7 @@ async function logNonHotswappableChanges( * * This logic prevents us from logging that change as non-hotswappable when we hotswap it. */ - if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { + if (hotswapMode === 'hotswap-only') { nonHotswappableChanges = nonHotswappableChanges.filter((change) => change.hotswapOnlyVisible === true); if (nonHotswappableChanges.length === 0) { @@ -490,7 +538,7 @@ async function logNonHotswappableChanges( const messages = ['']; // start with empty line - if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { + if (hotswapMode === 'hotswap-only') { messages.push(format('%s %s', chalk.red('⚠️'), chalk.red('The following non-hotswappable changes were found. To reconcile these using CloudFormation, specify --hotswap-fallback'))); } else { messages.push(format('%s %s', chalk.red('⚠️'), chalk.red('The following non-hotswappable changes were found:'))); @@ -516,5 +564,5 @@ async function logNonHotswappableChanges( } messages.push(''); // newline - await ioHelper.notify(info(messages.join('\n'))); + await ioSpan.notify(IO.DEFAULT_TOOLKIT_INFO.msg(messages.join('\n'))); } diff --git a/packages/aws-cdk/lib/api/hotswap/common.ts b/packages/aws-cdk/lib/api/hotswap/common.ts index 0cf6a5c29..99c0bf738 100644 --- a/packages/aws-cdk/lib/api/hotswap/common.ts +++ b/packages/aws-cdk/lib/api/hotswap/common.ts @@ -1,10 +1,35 @@ import type { PropertyDifference } from '@aws-cdk/cloudformation-diff'; +import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; import type { HotswappableChange, ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; import { ToolkitError } from '../../toolkit/error'; import type { SDK } from '../aws-auth'; export const ICON = '✨'; +/** + * The result of an attempted hotswap deployment + */ +export interface HotswapResult { + /** + * The stack that was hotswapped + */ + readonly stack: CloudFormationStackArtifact; + /** + * Whether hotswapping happened or not. + * + * `false` indicates that the deployment could not be hotswapped and full deployment may be attempted as fallback. + */ + readonly hotswapped: boolean; + /** + * The changes that were deemed hotswappable + */ + readonly hotswappableChanges: any[]; + /** + * The changes that were deemed not hotswappable + */ + readonly nonHotswappableChanges: any[]; +} + export interface HotswapOperation { /** * Marks the operation as hotswappable diff --git a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts index 2887fd263..14d6fd443 100644 --- a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts +++ b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts @@ -3,7 +3,7 @@ import type { PropertyDifference } from '@aws-cdk/cloudformation-diff'; import type { FunctionConfiguration, UpdateFunctionConfigurationCommandInput } from '@aws-sdk/client-lambda'; import type { ChangeHotswapResult } from './common'; import { classifyChanges } from './common'; -import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; +import type { AffectedResource, ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; import { ToolkitError } from '../../toolkit/error'; import { flatMap } from '../../util'; import type { ILambdaClient, SDK } from '../aws-auth'; @@ -66,7 +66,7 @@ export async function isHotswappableLambdaFunctionChange( service: 'lambda', resourceNames: [ `Lambda Function '${functionName}'`, - ...dependencies.map(d => d.description), + ...dependencies.map(d => d.description ?? `${d.resourceType} '${d.physicalName}'`), ], apply: async (sdk: SDK) => { const lambda = sdk.lambda(); @@ -354,12 +354,7 @@ async function dependantResources( logicalId: string, functionName: string, evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise> { +): Promise> { const candidates = await versionsAndAliases(logicalId, evaluateCfnTemplate); // Limited set of updates per function