diff --git a/.projenrc.ts b/.projenrc.ts index 3502d0744..b2acce02b 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -702,6 +702,7 @@ const tmpToolkitHelpers = configureProject( 'fast-check', ], deps: [ + cloudAssemblySchema.name, 'archiver', 'glob', 'semver', diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json index 5033e4172..db1b166ca 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json @@ -104,6 +104,10 @@ "version": "5.6", "type": "build" }, + { + "name": "@aws-cdk/cloud-assembly-schema", + "type": "runtime" + }, { "name": "archiver", "type": "runtime" diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json index b534ad6ab..52429198e 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json @@ -37,7 +37,7 @@ }, "steps": [ { - "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@stylistic/eslint-plugin,@types/archiver,@types/jest,@types/semver,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,projen,ts-jest,archiver,glob,semver,uuid" + "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@stylistic/eslint-plugin,@types/archiver,@types/jest,@types/semver,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,projen,ts-jest,@aws-cdk/cloud-assembly-schema,archiver,glob,semver,uuid" } ] }, @@ -77,7 +77,7 @@ "name": "gather-versions", "steps": [ { - "exec": "node -e \"require(path.join(path.dirname(require.resolve('cdklabs-projen-project-types')), 'yarn', 'gather-versions.exec.js'))\" @aws-cdk/tmp-toolkit-helpers MAJOR --deps @aws-cdk/cdk-build-tools", + "exec": "node -e \"require(path.join(path.dirname(require.resolve('cdklabs-projen-project-types')), 'yarn', 'gather-versions.exec.js'))\" @aws-cdk/tmp-toolkit-helpers MAJOR --deps @aws-cdk/cloud-assembly-schema @aws-cdk/cdk-build-tools", "receiveArgs": true } ] diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/package.json b/packages/@aws-cdk/tmp-toolkit-helpers/package.json index 1cc255a69..b50188f08 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/package.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/package.json @@ -56,6 +56,7 @@ "typescript": "5.6" }, "dependencies": { + "@aws-cdk/cloud-assembly-schema": "^0.0.0", "archiver": "^7.0.1", "glob": "^11.0.1", "semver": "^7.7.1", 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 new file mode 100644 index 000000000..62f25a127 --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap.ts @@ -0,0 +1,151 @@ +import type { PropertyDifference, Resource } from '@aws-cdk/cloudformation-diff'; +import type * as cxapi from '@aws-cdk/cx-api'; +import type { ResourceMetadata } from '../../resource-metadata/resource-metadata'; + +/** + * 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 display name of the resource type + * Usually a more user friendly variation of the resource type. + */ + readonly displayType?: 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; + /** + * Resource metadata attached to the logical id from the cloud assembly + * + * This is only present if the resource is present in the current Cloud Assembly, + * i.e. resource deletions will not have metadata. + */ + readonly metadata?: ResourceMetadata; +} + +/** + * Represents a change in a resource + */ +export interface ResourceChange { + /** + * The logical ID of the resource which is being changed + */ + readonly logicalId: string; + /** + * The value the resource is being updated from + */ + readonly oldValue: Resource; + /** + * The value the resource is being updated to + */ + readonly newValue: Resource; + /** + * The changes made to the resource properties + */ + readonly propertyUpdates: Record>; + /** + * Resource metadata for the change from the cloud assembly + * + * This is only present if the resource is present in the current Cloud Assembly, + * i.e. resource deletions will not have metadata. + */ + readonly metadata?: ResourceMetadata; +} + +export interface HotswappableChange { + /** + * The resource change that is causing the hotswap. + */ + readonly cause: ResourceChange; + /** + * A list of resources that are being hotswapped as part of the change + */ + readonly resources: AffectedResource[]; +} + +export enum NonHotswappableReason { + /** + * Tags are not hotswappable + */ + TAGS = 'tags', + /** + * Changed resource properties are not hotswappable on this resource type + */ + PROPERTIES = 'properties', + /** + * A stack output has changed + */ + OUTPUT = 'output', + /** + * A dependant resource is not hotswappable + */ + DEPENDENCY_UNSUPPORTED = 'dependency-unsupported', + /** + * The resource type is not hotswappable + */ + RESOURCE_UNSUPPORTED = 'resource-unsupported', + /** + * The resource is created in the deployment + */ + RESOURCE_CREATION = 'resource-creation', + /** + * The resource is removed in the deployment + */ + RESOURCE_DELETION = 'resource-deletion', + /** + * The resource identified by the logical id has its type changed + */ + RESOURCE_TYPE_CHANGED = 'resource-type-changed', + /** + * The nested stack is created in the deployment + */ + NESTED_STACK_CREATION = 'nested-stack-creation', +} + +//////////////////////////////// + +/** + * The result of an attempted hotswap deployment + */ +export interface HotswapResult { + /** + * The stack that was hotswapped + */ + readonly stack: cxapi.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 HotswapDeployment { + readonly stack: cxapi.CloudFormationStackArtifact; +} + +export interface HotswapChange { + readonly displayName: string; +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/index.ts index 883a8cc5f..e6b73b48a 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/index.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/index.ts @@ -13,3 +13,4 @@ export * from './watch'; export * from './stack-details'; export * from './diff'; export * from './logs-monitor'; +export * from './hotswap'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/stack-activity.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/stack-activity.ts index e194d13f4..690abfbcf 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/stack-activity.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/stack-activity.ts @@ -1,7 +1,7 @@ -import type { MetadataEntry } from '@aws-cdk/cloud-assembly-schema'; import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; import type { StackEvent } from '@aws-sdk/client-cloudformation'; import type { StackProgress } from './progress'; +import type { ResourceMetadata } from '../../resource-metadata/resource-metadata'; /** * Payload when stack monitoring is starting or stopping for a given stack deployment. @@ -48,6 +48,9 @@ export interface StackActivity { /** * Additional resource metadata + * + * This information is only available if the information is available in the current cloud assembly. + * I.e. no `metadata` will not be available for resource deletion events. */ readonly metadata?: ResourceMetadata; @@ -56,8 +59,3 @@ export interface StackActivity { */ readonly progress: StackProgress; } - -export interface ResourceMetadata { - entry: MetadataEntry; - constructPath: string; -} 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 360af241d..d9d3d55d9 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 { AffectedResource, HotswapDeployment, HotswappableChange } from '../payloads/hotswap'; import type { StackDetailsPayload } from '../payloads/list'; import type { CloudWatchLogEvent, CloudWatchLogMonitorControlEvent } from '../payloads/logs-monitor'; import type { StackRollbackProgress } from '../payloads/rollback'; @@ -21,7 +22,7 @@ import type { FileWatchEvent, WatchSettings } from '../payloads/watch'; * - X900-X999 are reserved for results */ export const IO = { - // Defaults + // Defaults (0000) DEFAULT_TOOLKIT_INFO: make.info({ code: 'CDK_TOOLKIT_I0000', description: 'Default info messages emitted from the Toolkit', @@ -35,7 +36,7 @@ export const IO = { description: 'Default warning messages emitted from the Toolkit', }), - // 1: Synth + // 1: Synth (1xxx) CDK_TOOLKIT_I1000: make.info({ code: 'CDK_TOOLKIT_I1000', description: 'Provides synthesis times.', @@ -57,7 +58,7 @@ export const IO = { interface: 'AssemblyData', }), - // 2: List + // 2: List (2xxx) CDK_TOOLKIT_I2901: make.result({ code: 'CDK_TOOLKIT_I2901', description: 'Provides details on the selected stacks and their dependencies', @@ -70,9 +71,9 @@ export const IO = { description: 'Resource import failed', }), - // 4: Diff + // 4: Diff (4xxx) - // 5: Deploy & Watch + // 5: Deploy & Watch (5xxx) CDK_TOOLKIT_I5000: make.info({ code: 'CDK_TOOLKIT_I5000', description: 'Provides deployment times', @@ -129,14 +130,13 @@ export const IO = { description: 'Confirm deploy security sensitive changes', interface: 'DeployConfirmationRequest', }), - CDK_TOOLKIT_I5100: make.info({ code: 'CDK_TOOLKIT_I5100', description: 'Stack deploy progress', interface: 'StackDeployProgress', }), - // Assets + // Assets (52xx) CDK_TOOLKIT_I5210: make.trace({ code: 'CDK_TOOLKIT_I5210', description: 'Started building a specific asset', @@ -147,7 +147,6 @@ export const IO = { description: 'Building the asset has completed', interface: 'Duration', }), - CDK_TOOLKIT_I5220: make.trace({ code: 'CDK_TOOLKIT_I5220', description: 'Started publishing a specific asset', @@ -159,7 +158,7 @@ export const IO = { interface: 'Duration', }), - // Watch + // Watch (53xx) CDK_TOOLKIT_I5310: make.debug({ code: 'CDK_TOOLKIT_I5310', description: 'The computed settings used for file watching', @@ -189,7 +188,39 @@ export const IO = { description: 'Queued watch deployment started', }), - // Stack Monitor + // Hotswap (54xx) + CDK_TOOLKIT_I5400: make.trace({ + code: 'CDK_TOOLKIT_I5400', + description: 'Starting hotswap deployment', + interface: 'HotswapDeployment', + }), + CDK_TOOLKIT_I5401: make.trace({ + code: 'CDK_TOOLKIT_I5401', + description: 'A hotswappable change is processed as part of a hotswap deployment', + interface: 'HotswappableChange', + }), + CDK_TOOLKIT_I5402: make.trace({ + code: 'CDK_TOOLKIT_I5402', + description: 'The hotswappable change has completed', + interface: 'HotswappableChange', + }), + CDK_TOOLKIT_I5403: make.info({ + code: 'CDK_TOOLKIT_I5403', + description: 'Resource affected by the current hotswap operation', + interface: 'AffectedResource', + }), + CDK_TOOLKIT_I5404: make.info({ + code: 'CDK_TOOLKIT_I5404', + description: 'Resource affected by the current hotswap operation has finished changing', + interface: 'HotswapChange', + }), + CDK_TOOLKIT_I5410: make.info({ + code: 'CDK_TOOLKIT_I5410', + description: 'Hotswap deployment end, a full deployment might still follow if needed', + interface: 'Duration', + }), + + // Stack Monitor (55xx) CDK_TOOLKIT_I5501: make.info({ code: 'CDK_TOOLKIT_I5501', description: 'Stack Monitoring: Start monitoring of a single stack', @@ -206,7 +237,7 @@ export const IO = { interface: 'StackMonitoringControlEvent', }), - // Success + // Success (59xx) CDK_TOOLKIT_I5900: make.result({ code: 'CDK_TOOLKIT_I5900', description: 'Deployment results on success', @@ -232,7 +263,7 @@ export const IO = { interface: 'ErrorPayload', }), - // 6: Rollback + // 6: Rollback (6xxx) CDK_TOOLKIT_I6000: make.info({ code: 'CDK_TOOLKIT_I6000', description: 'Provides rollback times', @@ -254,7 +285,7 @@ export const IO = { interface: 'ErrorPayload', }), - // 7: Destroy + // 7: Destroy (7xxx) CDK_TOOLKIT_I7000: make.info({ code: 'CDK_TOOLKIT_I7000', description: 'Provides destroy times', @@ -297,7 +328,7 @@ export const IO = { interface: 'ErrorPayload', }), - // 9: Bootstrap + // 9: Bootstrap (9xxx) CDK_TOOLKIT_I9000: make.info({ code: 'CDK_TOOLKIT_I9000', description: 'Provides bootstrap times', @@ -443,4 +474,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/tmp-toolkit-helpers/src/api/resource-metadata/resource-metadata.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/resource-metadata/resource-metadata.ts new file mode 100644 index 000000000..fc5e4b1d1 --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/resource-metadata/resource-metadata.ts @@ -0,0 +1,57 @@ +import { ArtifactMetadataEntryType, type MetadataEntry } from '@aws-cdk/cloud-assembly-schema'; +import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; + +/** + * Metadata entry for a resource within a CloudFormation stack + */ +export interface ResourceMetadata { + /** + * The resource's metadata as declared in the cloud assembly + */ + readonly entry: MetadataEntry; + /** + * The construct path of the resource + */ + readonly constructPath: string; +} + +/** + * Attempts to read metadata for resources from a CloudFormation stack artifact + * + * @param stack The CloudFormation stack to read from + * @param logicalId The logical ID of the resource to read + * + * @returns The resource metadata, or undefined if the resource was not found + */ +export function resourceMetadata(stack: CloudFormationStackArtifact, logicalId: string): ResourceMetadata | undefined { + const metadata = stack.manifest?.metadata; + if (!metadata) { + return undefined; + } + + for (const path of Object.keys(metadata)) { + const entry = metadata[path] + .filter((e) => e.type === ArtifactMetadataEntryType.LOGICAL_ID) + .find((e) => e.data === logicalId); + if (entry) { + return { + entry, + constructPath: simplifyConstructPath(path, stack.stackName), + }; + } + } + return undefined; +} + +function simplifyConstructPath(path: string, stackName: string) { + path = path.replace(/\/Resource$/, ''); + path = path.replace(/^\//, ''); // remove "/" prefix + + // remove "/" prefix + if (stackName) { + if (path.startsWith(stackName + '/')) { + path = path.slice(stackName.length + 1); + } + } + return path; +} diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index 4c0f070a8..b8277003d 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -38,6 +38,12 @@ 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 hotswap deployment | `trace` | {@link HotswapDeployment} | +| `CDK_TOOLKIT_I5401` | A hotswappable change is processed as part of a hotswap deployment | `trace` | {@link HotswappableChange} | +| `CDK_TOOLKIT_I5402` | The hotswappable change has completed | `trace` | {@link HotswappableChange} | +| `CDK_TOOLKIT_I5403` | Resource affected by the current hotswap operation | `info` | {@link AffectedResource} | +| `CDK_TOOLKIT_I5404` | Resource affected by the current hotswap operation has finished changing | `info` | {@link HotswapChange} | +| `CDK_TOOLKIT_I5410` | Hotswap deployment end, 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..0c7b897d1 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)); + 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 e90b63fa1..1f1a595fa 100644 --- a/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts @@ -3,11 +3,13 @@ import * as cfn_diff from '@aws-cdk/cloudformation-diff'; import type * as cxapi from '@aws-cdk/cx-api'; import type { WaiterResult } from '@smithy/util-waiter'; import * as chalk from 'chalk'; +import type { SuccessfulDeployStackResult } from '../../../../@aws-cdk/tmp-toolkit-helpers'; +import { NonHotswappableReason, type HotswapResult, type ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; +import { resourceMetadata } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/resource-metadata/resource-metadata'; 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 { type IoHelper, IO, type IMessageSpan, SPAN } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; import { ToolkitError } from '../../toolkit/error'; import { formatErrorMessage } from '../../util'; import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; @@ -15,9 +17,8 @@ import { isHotswappableAppSyncChange } from '../hotswap/appsync-mapping-template import { isHotswappableCodeBuildProjectChange } from '../hotswap/code-build-projects'; import type { ChangeHotswapResult, - HotswappableChange, + HotswapOperation, NonHotswappableChange, - HotswappableChangeCandidate, HotswapPropertyOverrides, ClassifiedResourceChanges, } from '../hotswap/common'; import { @@ -34,8 +35,7 @@ import { } from '../hotswap/s3-bucket-deployments'; import { isHotswappableStateMachineChange } from '../hotswap/stepfunctions-state-machines'; import { Mode } from '../plugin'; -import type { SuccessfulDeployStackResult } from './deployment-result'; -import type { IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; +import type { CloudFormationStack } from './cloudformation'; // Must use a require() otherwise esbuild complains about calling a namespace // eslint-disable-next-line @typescript-eslint/no-require-imports,@typescript-eslint/consistent-type-imports @@ -43,7 +43,7 @@ const pLimit: typeof import('p-limit') = require('p-limit'); type HotswapDetector = ( logicalId: string, - change: HotswappableChangeCandidate, + change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, hotswapPropertyOverrides: HotswapPropertyOverrides, ) => Promise; @@ -66,7 +66,7 @@ const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { 'Custom::CDKBucketDeployment': isHotswappableS3BucketDeploymentChange, 'AWS::IAM::Policy': async ( logicalId: string, - change: HotswappableChangeCandidate, + change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, ): Promise => { // If the policy is for a S3BucketDeploymentChange, we can ignore the change @@ -74,7 +74,11 @@ const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { return []; } - return reportNonHotswappableResource(change, 'This resource type is not supported for hotswap deployments'); + return reportNonHotswappableResource( + change, + NonHotswappableReason.RESOURCE_UNSUPPORTED, + 'This resource type is not supported for hotswap deployments', + ); }, 'AWS::CDK::Metadata': async () => [], @@ -92,19 +96,58 @@ 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 }); + + const result = await hotswapDeployment( + sdkProvider, + hotswapSpan, + assetParams, + stackArtifact, + hotswapMode, + hotswapPropertyOverrides, + ); + + await hotswapSpan.end(result); + + 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. + * If it's not possible to short-circuit the deployment + * (because the CDK Stack contains changes that cannot be deployed without CloudFormation), + * returns `undefined`. + */ +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({ - stackName: stackArtifact.stackName, - template: stackArtifact.template, + 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 + // In FALL_BACK mode, if there are any not hotswappable changes, then we don't hotswap at all if (hotswapMode === 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, }; } @@ -155,15 +203,17 @@ async function classifyResourceChanges( const resourceDifferences = getStackResourceDifferences(stackChanges); const promises: Array<() => Promise> = []; - const hotswappableResources = new Array(); + const hotswappableResources = new Array(); const nonHotswappableResources = new Array(); for (const logicalId of Object.keys(stackChanges.outputs.changes)) { nonHotswappableResources.push({ hotswappable: false, - reason: 'output was changed', + reason: NonHotswappableReason.OUTPUT, + displayReason: 'output was changed', logicalId, - rejectedChanges: [], - resourceType: 'Stack Output', + subject: { + type: 'Output', + }, }); } // gather the results of the detector functions @@ -183,7 +233,7 @@ async function classifyResourceChanges( continue; } - const hotswappableChangeCandidate = isCandidateForHotswapping(change, logicalId); + const hotswappableChangeCandidate = isCandidateForHotswapping(evaluateCfnTemplate.stackArtifact, change, logicalId); // we don't need to run this through the detector functions, we can already judge this if ('hotswappable' in hotswappableChangeCandidate) { if (!hotswappableChangeCandidate.hotswappable) { @@ -203,6 +253,7 @@ async function classifyResourceChanges( reportNonHotswappableChange( nonHotswappableResources, hotswappableChangeCandidate, + NonHotswappableReason.RESOURCE_UNSUPPORTED, undefined, 'This resource type is not supported for hotswap deployments', ); @@ -285,24 +336,27 @@ function filterDict(dict: { [key: string]: T }, func: (t: T) => boolean): { [ /** Finds any hotswappable changes in all nested stacks. */ async function findNestedHotswappableChanges( - logicalId: string, + stackLogicalId: string, change: cfn_diff.ResourceDifference, nestedStackTemplates: { [nestedStackName: string]: NestedStackTemplates }, evaluateCfnTemplate: EvaluateCloudFormationTemplate, sdk: SDK, hotswapPropertyOverrides: HotswapPropertyOverrides, ): Promise { - const nestedStack = nestedStackTemplates[logicalId]; + const nestedStack = nestedStackTemplates[stackLogicalId]; if (!nestedStack.physicalName) { return { hotswappableChanges: [], nonHotswappableChanges: [ { hotswappable: false, - logicalId, - reason: `physical name for AWS::CloudFormation::Stack '${logicalId}' could not be found in CloudFormation, so this is a newly created nested stack and cannot be hotswapped`, - rejectedChanges: [], - resourceType: 'AWS::CloudFormation::Stack', + logicalId: stackLogicalId, + reason: NonHotswappableReason.NESTED_STACK_CREATION, + displayReason: `physical name for AWS::CloudFormation::Stack '${stackLogicalId}' could not be found in CloudFormation, so this is a newly created nested stack and cannot be hotswapped`, + subject: { + type: 'Resource', + resourceType: 'AWS::CloudFormation::Stack', + }, }, ], }; @@ -315,16 +369,17 @@ async function findNestedHotswappableChanges( ); const nestedDiff = cfn_diff.fullDiff( - nestedStackTemplates[logicalId].deployedTemplate, - nestedStackTemplates[logicalId].generatedTemplate, + nestedStackTemplates[stackLogicalId].deployedTemplate, + nestedStackTemplates[stackLogicalId].generatedTemplate, ); return classifyResourceChanges( nestedDiff, evaluateNestedCfnTemplate, sdk, - nestedStackTemplates[logicalId].nestedStackTemplates, - hotswapPropertyOverrides); + nestedStackTemplates[stackLogicalId].nestedStackTemplates, + hotswapPropertyOverrides, + ); } /** Returns 'true' if a pair of changes is for the same resource. */ @@ -359,30 +414,38 @@ function makeRenameDifference( } /** - * Returns a `HotswappableChangeCandidate` if the change is hotswappable - * Returns an empty `HotswappableChange` if the change is to CDK::Metadata + * Returns a `ResourceChange` if the change is hotswappable * Returns a `NonHotswappableChange` if the change is not hotswappable */ function isCandidateForHotswapping( + stack: cxapi.CloudFormationStackArtifact, change: cfn_diff.ResourceDifference, logicalId: string, -): HotswappableChange | NonHotswappableChange | HotswappableChangeCandidate { +): ResourceChange | NonHotswappableChange { // a resource has been removed OR a resource has been added; we can't short-circuit that change if (!change.oldValue) { return { hotswappable: false, - resourceType: change.newValue!.Type, + subject: { + type: 'Resource', + resourceType: change.newValue!.Type, + }, logicalId, - rejectedChanges: [], - reason: `resource '${logicalId}' was created by this deployment`, + reason: NonHotswappableReason.RESOURCE_CREATION, + displayReason: `resource '${logicalId}' was created by this deployment`, + metadata: resourceMetadata(stack, logicalId), }; } else if (!change.newValue) { return { hotswappable: false, - resourceType: change.oldValue!.Type, + subject: { + type: 'Resource', + resourceType: change.oldValue!.Type, + }, logicalId, - rejectedChanges: [], - reason: `resource '${logicalId}' was destroyed by this deployment`, + reason: NonHotswappableReason.RESOURCE_DELETION, + displayReason: `resource '${logicalId}' was destroyed by this deployment`, + metadata: resourceMetadata(stack, logicalId), }; } @@ -390,10 +453,14 @@ function isCandidateForHotswapping( if (change.newValue?.Type !== change.oldValue?.Type) { return { hotswappable: false, - resourceType: change.newValue?.Type, + subject: { + type: 'Resource', + resourceType: change.newValue.Type, + }, logicalId, - rejectedChanges: [], - reason: `resource '${logicalId}' had its type changed from '${change.oldValue?.Type}' to '${change.newValue?.Type}'`, + reason: NonHotswappableReason.RESOURCE_TYPE_CHANGED, + displayReason: `resource '${logicalId}' had its type changed from '${change.oldValue?.Type}' to '${change.newValue?.Type}'`, + metadata: resourceMetadata(stack, logicalId), }; } @@ -402,27 +469,34 @@ function isCandidateForHotswapping( oldValue: change.oldValue, newValue: change.newValue, propertyUpdates: change.propertyUpdates, + metadata: resourceMetadata(stack, logicalId), }; } -async function applyAllHotswappableChanges(sdk: SDK, ioHelper: IoHelper, hotswappableChanges: HotswappableChange[]): 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: HotswappableChange): 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.CDK_TOOLKIT_I5401.msg( + `Hotswapping ${hotswapOperation.change.cause.logicalId} (${hotswapOperation.change.cause.newValue.Type})...`, + hotswapOperation.change, + )); + + for (const resource of hotswapOperation.change.resources) { + const displayResource = `${resource.displayType ?? resource.resourceType} '${resource.physicalName}'`; + await ioSpan.notify(IO.CDK_TOOLKIT_I5403.msg(` ${ICON} ${chalk.bold(displayResource)}`, resource)); } // if the SDK call fails, an error will be thrown by the SDK @@ -439,10 +513,16 @@ async function applyHotswappableChange(sdk: SDK, ioHelper: IoHelper, hotswapOper throw e; } - for (const name of hotswapOperation.resourceNames) { - await ioHelper.notify(info(format(`${ICON} %s %s`, chalk.bold(name), chalk.green('hotswapped!')))); + for (const resource of hotswapOperation.change.resources) { + const displayResource = `${resource.displayType ?? resource.resourceType} '${resource.physicalName}'`; + await ioSpan.notify(IO.CDK_TOOLKIT_I5404.msg(` ${ICON} ${chalk.bold(displayResource)} ${chalk.green('hotswapped!')}`, resource)); } + await ioSpan.notify(IO.CDK_TOOLKIT_I5402.msg( + `Hotswapping ${hotswapOperation.change.cause.logicalId} (${hotswapOperation.change.cause.newValue.Type})... Done`, + hotswapOperation.change, + )); + sdk.removeCustomUserAgent(customUserAgent); } @@ -465,14 +545,19 @@ function formatWaiterErrorResult(result: WaiterResult) { } async function logNonHotswappableChanges( - ioHelper: IoHelper, + ioSpan: IMessageSpan, nonHotswappableChanges: NonHotswappableChange[], hotswapMode: HotswapMode, ): Promise { if (nonHotswappableChanges.length === 0) { return; } + /** + * By default, not hotswappable changes are just skipped. + * + * However not all not hotswappable changes are skippable. In HOTSWAP_ONLY mode, we + * In HOTSWAP_ONLY * EKS Services can have a task definition that doesn't refer to the task definition being updated. * We have to log this as a non-hotswappable change to the task definition, but when we do, * we wind up hotswapping the task definition and logging it as a non-hotswappable change. @@ -480,9 +565,8 @@ async function logNonHotswappableChanges( * This logic prevents us from logging that change as non-hotswappable when we hotswap it. */ if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - nonHotswappableChanges = nonHotswappableChanges.filter((change) => change.hotswapOnlyVisible === true); - - if (nonHotswappableChanges.length === 0) { + const reportableChanges = nonHotswappableChanges.filter((change) => change.hotswapOnlyVisible === true); + if (reportableChanges.length === 0) { return; } } @@ -496,24 +580,24 @@ async function logNonHotswappableChanges( } for (const change of nonHotswappableChanges) { - if (change.rejectedChanges.length > 0) { + if (change.rejectedChanges?.length) { messages.push(format( ' logicalID: %s, type: %s, rejected changes: %s, reason: %s', chalk.bold(change.logicalId), - chalk.bold(change.resourceType), + chalk.bold(change.subject), chalk.bold(change.rejectedChanges), - chalk.red(change.reason), + chalk.red(change.displayReason), )); } else { messages.push(format( ' logicalID: %s, type: %s, reason: %s', chalk.bold(change.logicalId), - chalk.bold(change.resourceType), - chalk.red(change.reason), + chalk.bold(change.subject), + chalk.red(change.displayReason), )); } } 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/evaluate-cloudformation-template.ts b/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts index 4ccdb84e3..2a94143c4 100644 --- a/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts +++ b/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts @@ -1,6 +1,9 @@ +import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; import type { Export, ListExportsCommandOutput, StackResourceSummary } from '@aws-sdk/client-cloudformation'; import type { SDK } from './aws-auth'; import type { NestedStackTemplates } from './deployments'; +import { resourceMetadata } from '../../../@aws-cdk/tmp-toolkit-helpers/src/api/resource-metadata/resource-metadata'; +import type { ResourceMetadata } from '../../../@aws-cdk/tmp-toolkit-helpers/src/api/resource-metadata/resource-metadata'; import { ToolkitError } from '../toolkit/error'; export interface ListStackResources { @@ -85,8 +88,9 @@ export interface ResourceDefinition { } export interface EvaluateCloudFormationTemplateProps { - readonly stackName: string; - readonly template: Template; + readonly stackArtifact: CloudFormationStackArtifact; + readonly stackName?: string; + readonly template?: Template; readonly parameters: { [parameterName: string]: string }; readonly account: string; readonly region: string; @@ -98,6 +102,7 @@ export interface EvaluateCloudFormationTemplateProps { } export class EvaluateCloudFormationTemplate { + public readonly stackArtifact: CloudFormationStackArtifact; private readonly stackName: string; private readonly template: Template; private readonly context: { [k: string]: any }; @@ -114,8 +119,9 @@ export class EvaluateCloudFormationTemplate { private cachedUrlSuffix: string | undefined; constructor(props: EvaluateCloudFormationTemplateProps) { - this.stackName = props.stackName; - this.template = props.template; + this.stackArtifact = props.stackArtifact; + this.stackName = props.stackName ?? props.stackArtifact.stackName; + this.template = props.template ?? props.stackArtifact.template; this.context = { 'AWS::AccountId': props.account, 'AWS::Region': props.region, @@ -147,6 +153,7 @@ export class EvaluateCloudFormationTemplate { ) { const evaluatedParams = await this.evaluateCfnExpression(nestedStackParameters); return new EvaluateCloudFormationTemplate({ + stackArtifact: this.stackArtifact, stackName, template: nestedTemplate, parameters: evaluatedParams, @@ -312,6 +319,10 @@ export class EvaluateCloudFormationTemplate { return this.template.Resources?.[logicalId]?.Properties?.[propertyName]; } + public metadataFor(logicalId: string): ResourceMetadata | undefined { + return resourceMetadata(this.stackArtifact, logicalId); + } + private references(logicalId: string, templateElement: any): boolean { if (typeof templateElement === 'string') { return logicalId === templateElement; diff --git a/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts b/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts index b6be17cbf..33ce4f214 100644 --- a/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts +++ b/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts @@ -5,10 +5,10 @@ import type { import { type ChangeHotswapResult, classifyChanges, - type HotswappableChangeCandidate, lowerCaseFirstCharacter, transformObjectKeys, } from './common'; +import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers'; import { ToolkitError } from '../../toolkit/error'; import type { SDK } from '../aws-auth'; @@ -16,7 +16,7 @@ import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation- export async function isHotswappableAppSyncChange( logicalId: string, - change: HotswappableChangeCandidate, + change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, ): Promise { const isResolver = change.newValue.Type === 'AWS::AppSync::Resolver'; @@ -55,17 +55,23 @@ export async function isHotswappableAppSyncChange( } else { physicalName = arn; } + + if (!physicalName) { + return []; + } + ret.push({ + change: { + cause: change, + resources: [{ + logicalId, + resourceType: change.newValue.Type, + physicalName, + }], + }, hotswappable: true, - resourceType: change.newValue.Type, - propsChanged: namesOfHotswappableChanges, service: 'appsync', - resourceNames: [`${change.newValue.Type} '${physicalName}'`], apply: async (sdk: SDK) => { - if (!physicalName) { - return; - } - const sdkProperties: { [name: string]: any } = { ...change.oldValue.Properties, Definition: change.newValue.Properties?.Definition, diff --git a/packages/aws-cdk/lib/api/hotswap/code-build-projects.ts b/packages/aws-cdk/lib/api/hotswap/code-build-projects.ts index 0b2e57e6f..02df87dd5 100644 --- a/packages/aws-cdk/lib/api/hotswap/code-build-projects.ts +++ b/packages/aws-cdk/lib/api/hotswap/code-build-projects.ts @@ -2,16 +2,16 @@ import type { UpdateProjectCommandInput } from '@aws-sdk/client-codebuild'; import { type ChangeHotswapResult, classifyChanges, - type HotswappableChangeCandidate, lowerCaseFirstCharacter, transformObjectKeys, } from './common'; +import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers'; import type { SDK } from '../aws-auth'; import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; export async function isHotswappableCodeBuildProjectChange( logicalId: string, - change: HotswappableChangeCandidate, + change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, ): Promise { if (change.newValue.Type !== 'AWS::CodeBuild::Project') { @@ -30,16 +30,24 @@ export async function isHotswappableCodeBuildProjectChange( logicalId, change.newValue.Properties?.Name, ); + + if (!projectName) { + return []; + } + ret.push({ + change: { + cause: change, + resources: [{ + resourceType: change.newValue.Type, + physicalName: projectName, + logicalId: logicalId, + metadata: evaluateCfnTemplate.metadataFor(logicalId), + }], + }, hotswappable: true, - resourceType: change.newValue.Type, - propsChanged: classifiedChanges.namesOfHotswappableProps, service: 'codebuild', - resourceNames: [`CodeBuild Project '${projectName}'`], apply: async (sdk: SDK) => { - if (!projectName) { - return; - } updateProjectInput.name = projectName; for (const updatedPropName in change.propertyUpdates) { diff --git a/packages/aws-cdk/lib/api/hotswap/common.ts b/packages/aws-cdk/lib/api/hotswap/common.ts index 6b23fc3da..91eeb0d3f 100644 --- a/packages/aws-cdk/lib/api/hotswap/common.ts +++ b/packages/aws-cdk/lib/api/hotswap/common.ts @@ -1,13 +1,19 @@ -import type { PropertyDifference, Resource } from '@aws-cdk/cloudformation-diff'; +import type { PropertyDifference } from '@aws-cdk/cloudformation-diff'; +import { NonHotswappableReason, type HotswappableChange, type ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; +import type { ResourceMetadata } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/resource-metadata/resource-metadata'; import { ToolkitError } from '../../toolkit/error'; import type { SDK } from '../aws-auth'; export const ICON = '✨'; -export interface HotswappableChange { +export interface HotswapOperation { readonly hotswappable: true; - readonly resourceType: string; - readonly propsChanged: Array; + + /** + * Description of the change that is applied as part of the operation + */ + readonly change: HotswappableChange; + /** * The name of the service being hotswapped. * Used to set a custom User-Agent for SDK calls. @@ -15,36 +21,67 @@ export interface HotswappableChange { readonly service: string; /** - * The names of the resources being hotswapped. + * Applies the hotswap operation */ - readonly resourceNames: string[]; - readonly apply: (sdk: SDK) => Promise; } +export type ChangeSubject = + | { type: 'Output' } + | { + type: 'Resource'; + resourceType: string; + }; + export interface NonHotswappableChange { + /** + * The change is not hotswappable + */ readonly hotswappable: false; - readonly resourceType: string; - readonly rejectedChanges: Array; + /** + * The CloudFormation resource type of the resource + */ + readonly subject: ChangeSubject; + /** + * A list of properties that caused the change to be not hotswappable + * + * If undefined or empty, the change is not hotswappable for a different reason and will be explained in `reason` + */ + readonly rejectedChanges?: Array; + /** + * The logical if of the resource + */ readonly logicalId: string; + /** + * Why was this change was deemed non-hotswappable + */ + readonly reason: string; /** * Tells the user exactly why this change was deemed non-hotswappable and what its logical ID is. - * If not specified, `reason` will be autofilled to state that the properties listed in `rejectedChanges` are not hotswappable. + * If not specified, `displayReason` default to state that the properties listed in `rejectedChanges` are not hotswappable. + */ + readonly displayReason?: string; + /** + * Resource metadata for the change from the cloud assembly + * + * This is only present if the resource is present in the current Cloud Assembly, + * i.e. resource deletions will not have metadata. */ - readonly reason?: string; + readonly metadata?: ResourceMetadata; /** - * Whether or not to show this change when listing non-hotswappable changes in HOTSWAP_ONLY mode. Does not affect - * listing in FALL_BACK mode. + * Whether or not this not hotswappable change can be skipped in a hotswap deployment. + * + * If a change is not skippable, it forces a full deployment in FALL_BACK mode. * * @default true */ readonly hotswapOnlyVisible?: boolean; } -export type ChangeHotswapResult = Array; +export type ChangeHotswapResult = Array; export interface ClassifiedResourceChanges { - hotswappableChanges: HotswappableChange[]; + hotswappableChanges: HotswapOperation[]; nonHotswappableChanges: NonHotswappableChange[]; } @@ -65,38 +102,6 @@ export enum HotswapMode { FULL_DEPLOYMENT = 'full-deployment', } -/** - * Represents a change that can be hotswapped. - */ -export class HotswappableChangeCandidate { - /** - * The logical ID of the resource which is being changed - */ - public readonly logicalId: string; - - /** - * The value the resource is being updated from - */ - public readonly oldValue: Resource; - - /** - * The value the resource is being updated to - */ - public readonly newValue: Resource; - - /** - * The changes made to the resource properties - */ - public readonly propertyUpdates: PropDiffs; - - public constructor(logicalId: string, oldValue: Resource, newValue: Resource, propertyUpdates: PropDiffs) { - this.logicalId = logicalId; - this.oldValue = oldValue; - this.newValue = newValue; - this.propertyUpdates = propertyUpdates; - } -} - type Exclude = { [key: string]: Exclude | true }; /** @@ -182,11 +187,11 @@ export function lowerCaseFirstCharacter(str: string): string { return str.length > 0 ? `${str[0].toLowerCase()}${str.slice(1)}` : str; } -export type PropDiffs = Record>; +type PropDiffs = Record>; export class ClassifiedChanges { public constructor( - public readonly change: HotswappableChangeCandidate, + public readonly change: ResourceChange, public readonly hotswappableProps: PropDiffs, public readonly nonHotswappableProps: PropDiffs, ) { @@ -196,13 +201,15 @@ export class ClassifiedChanges { const nonHotswappablePropNames = Object.keys(this.nonHotswappableProps); if (nonHotswappablePropNames.length > 0) { const tagOnlyChange = nonHotswappablePropNames.length === 1 && nonHotswappablePropNames[0] === 'Tags'; + const reason = tagOnlyChange ? NonHotswappableReason.TAGS : NonHotswappableReason.PROPERTIES; + const displayReason = tagOnlyChange ? 'Tags are not hotswappable' : `resource properties '${nonHotswappablePropNames}' are not hotswappable on this resource type`; + reportNonHotswappableChange( ret, this.change, + reason, this.nonHotswappableProps, - tagOnlyChange - ? 'Tags are not hotswappable' - : `resource properties '${nonHotswappablePropNames}' are not hotswappable on this resource type`, + displayReason, ); } } @@ -212,7 +219,7 @@ export class ClassifiedChanges { } } -export function classifyChanges(xs: HotswappableChangeCandidate, hotswappablePropNames: string[]): ClassifiedChanges { +export function classifyChanges(xs: ResourceChange, hotswappablePropNames: string[]): ClassifiedChanges { const hotswappableProps: PropDiffs = {}; const nonHotswappableProps: PropDiffs = {}; @@ -229,36 +236,36 @@ export function classifyChanges(xs: HotswappableChangeCandidate, hotswappablePro export function reportNonHotswappableChange( ret: ChangeHotswapResult, - change: HotswappableChangeCandidate, + change: ResourceChange, + reason: NonHotswappableReason, nonHotswappableProps?: PropDiffs, - reason?: string, - hotswapOnlyVisible?: boolean, + displayReason?: string, + hotswapOnlyVisible: boolean = true, ): void { - let hotswapOnlyVisibility = true; - if (hotswapOnlyVisible === false) { - hotswapOnlyVisibility = false; - } ret.push({ hotswappable: false, rejectedChanges: Object.keys(nonHotswappableProps ?? change.propertyUpdates), logicalId: change.logicalId, - resourceType: change.newValue.Type, + subject: change.newValue.Type as any, reason, - hotswapOnlyVisible: hotswapOnlyVisibility, + displayReason, + hotswapOnlyVisible, }); } export function reportNonHotswappableResource( - change: HotswappableChangeCandidate, - reason?: string, + change: ResourceChange, + reason: NonHotswappableReason, + displayReason?: string, ): ChangeHotswapResult { return [ { hotswappable: false, rejectedChanges: Object.keys(change.propertyUpdates), logicalId: change.logicalId, - resourceType: change.newValue.Type, + subject: change.newValue.Type as any, reason, + displayReason, }, ]; } diff --git a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts index 906fdb1db..96355a0c6 100644 --- a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts +++ b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts @@ -1,19 +1,24 @@ +import type { ResourceChange } from '@aws-cdk/tmp-toolkit-helpers'; import type { HotswapPropertyOverrides, ChangeHotswapResult, - HotswappableChangeCandidate, + } from './common'; import { classifyChanges, lowerCaseFirstCharacter, + reportNonHotswappableChange, transformObjectKeys, } from './common'; +import { NonHotswappableReason } from '../../../../@aws-cdk/tmp-toolkit-helpers'; import type { SDK } from '../aws-auth'; import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; +const EcsServiceResourceType = 'AWS::ECS::Service'; + export async function isHotswappableEcsServiceChange( logicalId: string, - change: HotswappableChangeCandidate, + change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, hotswapPropertyOverrides: HotswapPropertyOverrides, ): Promise { @@ -33,28 +38,47 @@ export async function isHotswappableEcsServiceChange( // find all ECS Services that reference the TaskDefinition that changed const resourcesReferencingTaskDef = evaluateCfnTemplate.findReferencesTo(logicalId); const ecsServiceResourcesReferencingTaskDef = resourcesReferencingTaskDef.filter( - (r) => r.Type === 'AWS::ECS::Service', + (r) => r.Type === EcsServiceResourceType, ); const ecsServicesReferencingTaskDef = new Array(); for (const ecsServiceResource of ecsServiceResourcesReferencingTaskDef) { const serviceArn = await evaluateCfnTemplate.findPhysicalNameFor(ecsServiceResource.LogicalId); if (serviceArn) { - ecsServicesReferencingTaskDef.push({ serviceArn }); + ecsServicesReferencingTaskDef.push({ + logicalId: ecsServiceResource.LogicalId, + serviceArn, + }); } } if (ecsServicesReferencingTaskDef.length === 0) { // if there are no resources referencing the TaskDefinition, // hotswap is not possible in FALL_BACK mode - reportNonHotswappableChange(ret, change, undefined, 'No ECS services reference the changed task definition', false); + /** + * ECS Services can have a task definition that doesn't refer to the task definition being updated. + * We have to log this as a non-hotswappable change to the task definition, but when we do, + * we wind up hotswapping the task definition and logging it as a non-hotswappable change. + * + * This logic prevents us from logging that change as non-hotswappable when we hotswap it. + */ + + reportNonHotswappableChange( + ret, + change, + NonHotswappableReason.DEPENDENCY_UNSUPPORTED, // @todo + undefined, + 'No ECS services reference the changed task definition', + false, + ); } if (resourcesReferencingTaskDef.length > ecsServicesReferencingTaskDef.length) { // if something besides an ECS Service is referencing the TaskDefinition, // hotswap is not possible in FALL_BACK mode - const nonEcsServiceTaskDefRefs = resourcesReferencingTaskDef.filter((r) => r.Type !== 'AWS::ECS::Service'); + const nonEcsServiceTaskDefRefs = resourcesReferencingTaskDef.filter((r) => r.Type !== EcsServiceResourceType); for (const taskRef of nonEcsServiceTaskDefRefs) { reportNonHotswappableChange( ret, change, + NonHotswappableReason.DEPENDENCY_UNSUPPORTED, undefined, `A resource '${taskRef.LogicalId}' with Type '${taskRef.Type}' that is not an ECS Service was found referencing the changed TaskDefinition '${logicalId}'`, ); @@ -65,14 +89,25 @@ export async function isHotswappableEcsServiceChange( if (namesOfHotswappableChanges.length > 0) { const taskDefinitionResource = await prepareTaskDefinitionChange(evaluateCfnTemplate, logicalId, change); ret.push({ + change: { + cause: change, + resources: [ + { + resourceType: change.newValue.Type, + physicalName: await taskDefinitionResource.Family, + logicalId, + metadata: evaluateCfnTemplate.metadataFor(logicalId), + }, + ...ecsServicesReferencingTaskDef.map((ecsService) => ({ + resourceType: EcsServiceResourceType, + physicalName: ecsService.serviceArn.split('/')[2], + logicalId: ecsService.logicalId, + metadata: evaluateCfnTemplate.metadataFor(logicalId), + })), + ], + }, hotswappable: true, - resourceType: change.newValue.Type, - propsChanged: namesOfHotswappableChanges, service: 'ecs-service', - resourceNames: [ - `ECS Task Definition '${await taskDefinitionResource.Family}'`, - ...ecsServicesReferencingTaskDef.map((ecsService) => `ECS Service '${ecsService.serviceArn.split('/')[2]}'`), - ], apply: async (sdk: SDK) => { // Step 1 - update the changed TaskDefinition, creating a new TaskDefinition Revision // we need to lowercase the evaluated TaskDef from CloudFormation, @@ -138,13 +173,14 @@ export async function isHotswappableEcsServiceChange( } interface EcsService { + readonly logicalId: string; readonly serviceArn: string; } async function prepareTaskDefinitionChange( evaluateCfnTemplate: EvaluateCloudFormationTemplate, logicalId: string, - change: HotswappableChangeCandidate, + change: ResourceChange, ) { const taskDefinitionResource: { [name: string]: any } = { ...change.oldValue.Properties, diff --git a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts index dbff616e7..b9de4c266 100644 --- a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts +++ b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts @@ -1,6 +1,8 @@ import { Writable } from 'stream'; +import type { PropertyDifference } from '@aws-cdk/cloudformation-diff'; +import type { AffectedResource, ResourceChange } from '@aws-cdk/tmp-toolkit-helpers'; import type { FunctionConfiguration, UpdateFunctionConfigurationCommandInput } from '@aws-sdk/client-lambda'; -import type { PropDiffs, ChangeHotswapResult, HotswappableChangeCandidate } from './common'; +import type { ChangeHotswapResult } from './common'; import { classifyChanges } from './common'; import { ToolkitError } from '../../toolkit/error'; import { flatMap } from '../../util'; @@ -13,7 +15,7 @@ const archiver = require('archiver'); export async function isHotswappableLambdaFunctionChange( logicalId: string, - change: HotswappableChangeCandidate, + change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, ): Promise { // if the change is for a Lambda Version, @@ -23,10 +25,11 @@ export async function isHotswappableLambdaFunctionChange( if (change.newValue.Type === 'AWS::Lambda::Version') { return [ { + change: { + cause: change, + resources: [], + }, hotswappable: true, - resourceType: 'AWS::Lambda::Version', - resourceNames: [], - propsChanged: [], service: 'lambda', apply: async (_sdk: SDK) => { }, @@ -52,23 +55,24 @@ export async function isHotswappableLambdaFunctionChange( change.newValue.Properties?.FunctionName, ); const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps); - if (namesOfHotswappableChanges.length > 0) { + if (functionName && namesOfHotswappableChanges.length > 0) { + const dependencies = await dependantResources(logicalId, functionName, evaluateCfnTemplate); + ret.push({ + change: { + cause: change, + resources: [ + { + logicalId, + metadata: evaluateCfnTemplate.metadataFor(logicalId), + resourceType: change.newValue.Type, + physicalName: functionName, + }, + ...dependencies, + ], + }, hotswappable: true, - resourceType: change.newValue.Type, - propsChanged: namesOfHotswappableChanges, service: 'lambda', - resourceNames: [ - `Lambda Function '${functionName}'`, - // add Version here if we're publishing a new one - ...(await renderVersions(logicalId, evaluateCfnTemplate, [`Lambda Version for Function '${functionName}'`])), - // add any Aliases that we are hotswapping here - ...(await renderAliases( - logicalId, - evaluateCfnTemplate, - async (alias) => `Lambda Alias '${alias}' for Function '${functionName}'`, - )), - ], apply: async (sdk: SDK) => { const lambdaCodeChange = await evaluateLambdaFunctionProps( classifiedChanges.hotswappableProps, @@ -83,7 +87,6 @@ export async function isHotswappableLambdaFunctionChange( return; } - const { versionsReferencingFunction, aliasesNames } = await versionsAndAliases(logicalId, evaluateCfnTemplate); const lambda = sdk.lambda(); const operations: Promise[] = []; @@ -116,19 +119,21 @@ export async function isHotswappableLambdaFunctionChange( } // only if the code changed is there any point in publishing a new Version - if (versionsReferencingFunction.length > 0) { + const versions = dependencies.filter((d) => d.resourceType === 'AWS::Lambda::Version'); + if (versions.length) { const publishVersionPromise = lambda.publishVersion({ FunctionName: functionName, }); - if (aliasesNames.length > 0) { + const aliases = dependencies.filter((d) => d.resourceType === 'AWS::Lambda::Alias'); + if (aliases.length) { // we need to wait for the Version to finish publishing const versionUpdate = await publishVersionPromise; - for (const alias of aliasesNames) { + for (const alias of aliases) { operations.push( lambda.updateAlias({ FunctionName: functionName, - Name: alias, + Name: alias.physicalName, FunctionVersion: versionUpdate.Version, }), ); @@ -153,19 +158,20 @@ export async function isHotswappableLambdaFunctionChange( /** * Determines which changes to this Alias are hotswappable or not */ -function classifyAliasChanges(change: HotswappableChangeCandidate): ChangeHotswapResult { +function classifyAliasChanges(cause: ResourceChange): ChangeHotswapResult { const ret: ChangeHotswapResult = []; - const classifiedChanges = classifyChanges(change, ['FunctionVersion']); + const classifiedChanges = classifyChanges(cause, ['FunctionVersion']); classifiedChanges.reportNonHotswappablePropertyChanges(ret); const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps); if (namesOfHotswappableChanges.length > 0) { ret.push({ + change: { + cause, + resources: [], + }, hotswappable: true, - resourceType: change.newValue.Type, - propsChanged: [], service: 'lambda', - resourceNames: [], apply: async (_sdk: SDK) => { }, }); @@ -180,7 +186,7 @@ function classifyAliasChanges(change: HotswappableChangeCandidate): ChangeHotswa * Returns `undefined` if the change is not hotswappable. */ async function evaluateLambdaFunctionProps( - hotswappablePropChanges: PropDiffs, + hotswappablePropChanges: Record>, runtime: string, evaluateCfnTemplate: EvaluateCloudFormationTemplate, ): Promise { @@ -369,38 +375,41 @@ async function versionsAndAliases(logicalId: string, evaluateCfnTemplate: Evalua // find all Lambda Aliases that reference the above Versions const aliasesReferencingVersions = flatMap(versionsReferencingFunction, v => evaluateCfnTemplate.findReferencesTo(v.LogicalId)); - // Limited set of updates per function - // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism - const aliasesNames = await Promise.all(aliasesReferencingVersions.map(a => - evaluateCfnTemplate.evaluateCfnExpression(a.Properties?.Name))); - return { versionsReferencingFunction, aliasesNames }; + return { versionsReferencingFunction, aliasesReferencingVersions }; } -/** - * Renders the string used in displaying Alias resource names that reference the specified Lambda Function - */ -async function renderAliases( +async function dependantResources( logicalId: string, + functionName: string, evaluateCfnTemplate: EvaluateCloudFormationTemplate, - callbackfn: (value: any, index: number, array: any[]) => Promise, -): Promise { - const aliasesNames = (await versionsAndAliases(logicalId, evaluateCfnTemplate)).aliasesNames; +): Promise { + const candidates = await versionsAndAliases(logicalId, evaluateCfnTemplate); // Limited set of updates per function // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism - return Promise.all(aliasesNames.map(callbackfn)); -} - -/** - * Renders the string used in displaying Version resource names that reference the specified Lambda Function - */ -async function renderVersions( - logicalId: string, - evaluateCfnTemplate: EvaluateCloudFormationTemplate, - versionString: string[], -): Promise { - const versions = (await versionsAndAliases(logicalId, evaluateCfnTemplate)).versionsReferencingFunction; + const aliases = await Promise.all(candidates.aliasesReferencingVersions.map(async (a) => { + const name = await evaluateCfnTemplate.evaluateCfnExpression(a.Properties?.Name); + return { + logicalId: a.LogicalId, + metadata: evaluateCfnTemplate.metadataFor(a.LogicalId), + physicalName: name, + resourceType: 'AWS::Lambda::Alias', + displayType: `Lambda Alias '${name}' for Function '${functionName}'`, + }; + })); + + const versions = candidates.versionsReferencingFunction.map((v) => ( + { + logicalId: v.LogicalId, + metadata: evaluateCfnTemplate.metadataFor(v.LogicalId), + resourceType: v.Type, + displayType: `Lambda Version for Function '${functionName}'`, + } + )); - return versions.length > 0 ? versionString : []; + return [ + ...versions, + ...aliases, + ]; } diff --git a/packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts b/packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts index 1df6eb545..968b2693d 100644 --- a/packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts +++ b/packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts @@ -1,4 +1,5 @@ -import type { ChangeHotswapResult, HotswappableChangeCandidate } from './common'; +import type { ChangeHotswapResult } from './common'; +import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers'; import type { SDK } from '../aws-auth'; import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; @@ -6,18 +7,20 @@ import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation- * This means that the value is required to exist by CloudFormation's Custom Resource API (or our S3 Bucket Deployment Lambda's API) * but the actual value specified is irrelevant */ -export const REQUIRED_BY_CFN = 'required-to-be-present-by-cfn'; +const REQUIRED_BY_CFN = 'required-to-be-present-by-cfn'; + +const CDK_BUCKET_DEPLOYMENT_CFN_TYPE = 'Custom::CDKBucketDeployment'; export async function isHotswappableS3BucketDeploymentChange( - _logicalId: string, - change: HotswappableChangeCandidate, + logicalId: string, + change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, ): Promise { // In old-style synthesis, the policy used by the lambda to copy assets Ref's the assets directly, // meaning that the changes made to the Policy are artifacts that can be safely ignored const ret: ChangeHotswapResult = []; - if (change.newValue.Type !== 'Custom::CDKBucketDeployment') { + if (change.newValue.Type !== CDK_BUCKET_DEPLOYMENT_CFN_TYPE) { return []; } @@ -28,11 +31,17 @@ export async function isHotswappableS3BucketDeploymentChange( }); ret.push({ + change: { + cause: change, + resources: [{ + logicalId, + physicalName: customResourceProperties.DestinationBucketName, + resourceType: CDK_BUCKET_DEPLOYMENT_CFN_TYPE, + displayType: 'Contents of S3 Bucket', + }], + }, hotswappable: true, - resourceType: change.newValue.Type, - propsChanged: ['*'], service: 'custom-s3-deployment', - resourceNames: [`Contents of S3 Bucket '${customResourceProperties.DestinationBucketName}'`], apply: async (sdk: SDK) => { // note that this gives the ARN of the lambda, not the name. This is fine though, the invoke() sdk call will take either const functionName = await evaluateCfnTemplate.evaluateCfnExpression(change.newValue.Properties?.ServiceToken); @@ -61,7 +70,7 @@ export async function isHotswappableS3BucketDeploymentChange( export async function skipChangeForS3DeployCustomResourcePolicy( iamPolicyLogicalId: string, - change: HotswappableChangeCandidate, + change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, ): Promise { if (change.newValue.Type !== 'AWS::IAM::Policy') { diff --git a/packages/aws-cdk/lib/api/hotswap/stepfunctions-state-machines.ts b/packages/aws-cdk/lib/api/hotswap/stepfunctions-state-machines.ts index 55cfa67bf..1ac3b772e 100644 --- a/packages/aws-cdk/lib/api/hotswap/stepfunctions-state-machines.ts +++ b/packages/aws-cdk/lib/api/hotswap/stepfunctions-state-machines.ts @@ -1,10 +1,11 @@ -import { type ChangeHotswapResult, classifyChanges, type HotswappableChangeCandidate } from './common'; +import { type ChangeHotswapResult, classifyChanges } from './common'; +import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers'; import type { SDK } from '../aws-auth'; import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; export async function isHotswappableStateMachineChange( logicalId: string, - change: HotswappableChangeCandidate, + change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, ): Promise { if (change.newValue.Type !== 'AWS::StepFunctions::StateMachine') { @@ -25,11 +26,17 @@ export async function isHotswappableStateMachineChange( }) : await evaluateCfnTemplate.findPhysicalNameFor(logicalId); ret.push({ + change: { + cause: change, + resources: [{ + logicalId, + resourceType: change.newValue.Type, + physicalName: stateMachineArn?.split(':')[6], + metadata: evaluateCfnTemplate.metadataFor(logicalId), + }], + }, hotswappable: true, - resourceType: change.newValue.Type, - propsChanged: namesOfHotswappableChanges, service: 'stepfunctions-service', - resourceNames: [`${change.newValue.Type} '${stateMachineArn?.split(':')[6]}'`], apply: async (sdk: SDK) => { if (!stateMachineArn) { return; diff --git a/packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts b/packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts index 7cf48a756..ca1a3b356 100644 --- a/packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts +++ b/packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts @@ -53,8 +53,7 @@ export async function findCloudWatchLogGroups( const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName); const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ - stackName: stackArtifact.stackName, - template: stackArtifact.template, + stackArtifact, parameters: {}, account: resolvedEnv.account, region: resolvedEnv.region, diff --git a/packages/aws-cdk/lib/api/stack-events/stack-activity-monitor.ts b/packages/aws-cdk/lib/api/stack-events/stack-activity-monitor.ts index f7e3584af..36fb26850 100644 --- a/packages/aws-cdk/lib/api/stack-events/stack-activity-monitor.ts +++ b/packages/aws-cdk/lib/api/stack-events/stack-activity-monitor.ts @@ -1,10 +1,10 @@ import * as util from 'util'; -import { ArtifactMetadataEntryType } from '@aws-cdk/cloud-assembly-schema'; import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; -import type { ResourceMetadata, StackActivity, StackMonitoringControlEvent } from '@aws-cdk/tmp-toolkit-helpers'; +import type { StackActivity, StackMonitoringControlEvent } from '@aws-cdk/tmp-toolkit-helpers'; import * as uuid from 'uuid'; import { StackEventPoller } from './stack-event-poller'; +import { resourceMetadata } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/resource-metadata/resource-metadata'; import { debug, error, info } from '../../cli/messages'; import { stackEventHasErrorMessage } from '../../util'; import type { ICloudFormationClient } from '../aws-auth'; @@ -181,23 +181,12 @@ export class StackActivityMonitor { this.scheduleNextTick(); } - private findMetadataFor(logicalId: string | undefined): ResourceMetadata | undefined { + private findMetadataFor(logicalId: string | undefined) { const metadata = this.stack.manifest?.metadata; if (!logicalId || !metadata) { return undefined; } - for (const path of Object.keys(metadata)) { - const entry = metadata[path] - .filter((e) => e.type === ArtifactMetadataEntryType.LOGICAL_ID) - .find((e) => e.data === logicalId); - if (entry) { - return { - entry, - constructPath: this.simplifyConstructPath(path), - }; - } - } - return undefined; + return resourceMetadata(this.stack, logicalId); } /** @@ -278,15 +267,4 @@ export class StackActivityMonitor { } } } - - private simplifyConstructPath(path: string) { - path = path.replace(/\/Resource$/, ''); - path = path.replace(/^\//, ''); // remove "/" prefix - - // remove "/" prefix - if (path.startsWith(this.stackName + '/')) { - path = path.slice(this.stackName.length + 1); - } - return path; - } } diff --git a/packages/aws-cdk/test/api/hotswap/s3-bucket-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/s3-bucket-hotswap-deployments.test.ts index f6db9495f..484a9b866 100644 --- a/packages/aws-cdk/test/api/hotswap/s3-bucket-hotswap-deployments.test.ts +++ b/packages/aws-cdk/test/api/hotswap/s3-bucket-hotswap-deployments.test.ts @@ -1,12 +1,12 @@ import { InvokeCommand } from '@aws-sdk/client-lambda'; import * as setup from '../_helpers/hotswap-test-setup'; import { HotswapMode } from '../../../lib/api/hotswap/common'; -import { REQUIRED_BY_CFN } from '../../../lib/api/hotswap/s3-bucket-deployments'; import { mockLambdaClient } from '../../util/mock-sdk'; import { silentTest } from '../../util/silent'; let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; +const REQUIRED_BY_CFN = 'required-to-be-present-by-cfn'; const payloadWithoutCustomResProps = { RequestType: 'Update', ResponseURL: REQUIRED_BY_CFN,