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 034d73586..9e4fb40a0 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 @@ -109,6 +109,59 @@ export enum NonHotswappableReason { NESTED_STACK_CREATION = 'nested-stack-creation', } +export interface RejectionSubject { + /** + * The type of the rejection subject, e.g. Resource or Output + */ + readonly type: string; + + /** + * The logical ID of the change that is not hotswappable + */ + readonly logicalId: string; +} + +export interface ResourceSubject extends RejectionSubject { + /** + * A rejected resource + */ + readonly type: 'Resource'; + /** + * The type of the rejected resource + */ + readonly resourceType: string; + /** + * The list of properties that are cause for the rejection + */ + readonly rejectedProperties?: string[]; +} + +export interface OutputSubject extends RejectionSubject { + /** + * A rejected output + */ + readonly type: 'Output'; +} + +/** + * A change that can not be hotswapped + */ +export interface NonHotswappableChange { + /** + * The subject of the change that was rejected + */ + readonly subject: ResourceSubject | OutputSubject; + /** + * Why was this change was deemed non-hotswappable + */ + readonly reason: NonHotswappableReason; + /** + * Tells the user exactly why this change was deemed non-hotswappable and what its logical ID is. + * If not specified, `displayReason` default to state that the properties listed in `rejectedChanges` are not hotswappable. + */ + readonly description: string; +} + /** * Information about a hotswap deployment */ @@ -123,3 +176,31 @@ export interface HotswapDeployment { */ readonly mode: 'hotswap-only' | 'fall-back'; } + +/** + * The result of an attempted hotswap deployment + */ +export interface HotswapResult { + /** + * The stack that was hotswapped + */ + readonly stack: cxapi.CloudFormationStackArtifact; + /** + * The mode the hotswap deployment was initiated with. + */ + readonly mode: 'hotswap-only' | 'fall-back'; + /** + * 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: HotswappableChange[]; + /** + * The changes that were deemed not hotswappable + */ + readonly nonHotswappableChanges: NonHotswappableChange[]; +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/util/objects.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/util/objects.ts index 91872162d..2ff69c990 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/util/objects.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/util/objects.ts @@ -215,3 +215,35 @@ export function splitBySize(data: any, maxSizeBytes: number): [any, any] { ]; } } + +type Exclude = { [key: string]: Exclude | true }; + +/** + * This function transforms all keys (recursively) in the provided `val` object. + * + * @param val The object whose keys need to be transformed. + * @param transform The function that will be applied to each key. + * @param exclude The keys that will not be transformed and copied to output directly + * @returns A new object with the same values as `val`, but with all keys transformed according to `transform`. + */ +export function transformObjectKeys(val: any, transform: (str: string) => string, exclude: Exclude = {}): any { + if (val == null || typeof val !== 'object') { + return val; + } + if (Array.isArray(val)) { + // For arrays we just pass parent's exclude object directly + // since it makes no sense to specify different exclude options for each array element + return val.map((input: any) => transformObjectKeys(input, transform, exclude)); + } + const ret: { [k: string]: any } = {}; + for (const [k, v] of Object.entries(val)) { + const childExclude = exclude[k]; + if (childExclude === true) { + // we don't transform this object if the key is specified in exclude + ret[transform(k)] = v; + } else { + ret[transform(k)] = transformObjectKeys(v, transform, childExclude); + } + } + return ret; +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/util/string-manipulation.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/util/string-manipulation.ts index 098beacd1..0165abb3d 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/util/string-manipulation.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/util/string-manipulation.ts @@ -35,3 +35,10 @@ function roundPercentage(num: number): number { function millisecondsToSeconds(num: number): number { return num / 1000; } + +/** + * This function lower cases the first character of the string provided. + */ +export function lowerCaseFirstCharacter(str: string): string { + return str.length > 0 ? `${str[0].toLowerCase()}${str.slice(1)}` : str; +} diff --git a/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts b/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts index 913ac837e..623792cc6 100644 --- a/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/deployments/hotswap-deployments.ts @@ -3,7 +3,7 @@ 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 { AffectedResource, ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads'; +import type { AffectedResource, HotswapResult, ResourceSubject, ResourceChange, NonHotswappableChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads'; import { NonHotswappableReason } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads'; 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'; @@ -17,17 +17,14 @@ import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-templ import { isHotswappableAppSyncChange } from '../hotswap/appsync-mapping-templates'; import { isHotswappableCodeBuildProjectChange } from '../hotswap/code-build-projects'; import type { - ChangeHotswapResult, + HotswapChange, HotswapOperation, - NonHotswappableChange, + RejectedChange, HotswapPropertyOverrides, - ClassifiedResourceChanges, - HotswapResult, } from '../hotswap/common'; import { ICON, - reportNonHotswappableChange, - reportNonHotswappableResource, + nonHotswappableResource, } from '../hotswap/common'; import { isHotswappableEcsServiceChange } from '../hotswap/ecs-services'; import { isHotswappableLambdaFunctionChange } from '../hotswap/lambda-functions'; @@ -48,7 +45,7 @@ type HotswapDetector = ( change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, hotswapPropertyOverrides: HotswapPropertyOverrides, -) => Promise; +) => Promise; type HotswapMode = 'hotswap-only' | 'fall-back'; @@ -72,17 +69,13 @@ const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { logicalId: string, change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, - ): Promise => { + ): Promise => { // If the policy is for a S3BucketDeploymentChange, we can ignore the change if (await skipChangeForS3DeployCustomResourcePolicy(logicalId, change, evaluateCfnTemplate)) { return []; } - return reportNonHotswappableResource( - change, - NonHotswappableReason.RESOURCE_UNSUPPORTED, - 'This resource type is not supported for hotswap deployments', - ); + return [nonHotswappableResource(change)]; }, 'AWS::CDK::Metadata': async () => [], @@ -162,22 +155,24 @@ async function hotswapDeployment( }); const stackChanges = cfn_diff.fullDiff(currentTemplate.deployedRootTemplate, stack.template); - const { hotswapOperations, nonHotswappableChanges } = await classifyResourceChanges( + const { hotswappable, nonHotswappable } = await classifyResourceChanges( stackChanges, evaluateCfnTemplate, sdk, currentTemplate.nestedStacks, hotswapPropertyOverrides, ); - await logNonHotswappableChanges(ioSpan, nonHotswappableChanges, hotswapMode); + await logNonHotswappableChanges(ioSpan, nonHotswappable, hotswapMode); - const hotswappableChanges = hotswapOperations.map(o => o.change); + const hotswappableChanges = hotswappable.map(o => o.change); + const nonHotswappableChanges = nonHotswappable.map(n => n.change); // preserve classic hotswap behavior if (hotswapMode === 'fall-back') { if (nonHotswappableChanges.length > 0) { return { stack, + mode: hotswapMode, hotswapped: false, hotswappableChanges, nonHotswappableChanges, @@ -186,16 +181,22 @@ async function hotswapDeployment( } // apply the short-circuitable changes - await applyAllHotswappableChanges(sdk, ioSpan, hotswapOperations); + await applyAllHotswappableChanges(sdk, ioSpan, hotswappable); return { stack, + mode: hotswapMode, hotswapped: true, hotswappableChanges, nonHotswappableChanges, }; } +interface ClassifiedChanges { + hotswappable: HotswapOperation[]; + nonHotswappable: RejectedChange[]; +} + /** * Classifies all changes to all resources as either hotswappable or not. * Metadata changes are excluded from the list of (non)hotswappable resources. @@ -206,20 +207,23 @@ async function classifyResourceChanges( sdk: SDK, nestedStackNames: { [nestedStackName: string]: NestedStackTemplates }, hotswapPropertyOverrides: HotswapPropertyOverrides, -): Promise { +): Promise { const resourceDifferences = getStackResourceDifferences(stackChanges); - const promises: Array<() => Promise> = []; + const promises: Array<() => Promise> = []; const hotswappableResources = new Array(); - const nonHotswappableResources = new Array(); + const nonHotswappableResources = new Array(); for (const logicalId of Object.keys(stackChanges.outputs.changes)) { nonHotswappableResources.push({ hotswappable: false, - reason: NonHotswappableReason.OUTPUT, - description: 'output was changed', - logicalId, - rejectedChanges: [], - resourceType: 'Stack Output', + change: { + reason: NonHotswappableReason.OUTPUT, + description: 'output was changed', + subject: { + type: 'Output', + logicalId, + }, + }, }); } // gather the results of the detector functions @@ -233,8 +237,8 @@ async function classifyResourceChanges( sdk, hotswapPropertyOverrides, ); - hotswappableResources.push(...nestedHotswappableResources.hotswapOperations); - nonHotswappableResources.push(...nestedHotswappableResources.nonHotswappableChanges); + hotswappableResources.push(...nestedHotswappableResources.hotswappable); + nonHotswappableResources.push(...nestedHotswappableResources.nonHotswappable); continue; } @@ -256,18 +260,12 @@ async function classifyResourceChanges( RESOURCE_DETECTORS[resourceType](logicalId, hotswappableChangeCandidate, evaluateCfnTemplate, hotswapPropertyOverrides), ); } else { - reportNonHotswappableChange( - nonHotswappableResources, - hotswappableChangeCandidate, - NonHotswappableReason.RESOURCE_UNSUPPORTED, - undefined, - 'This resource type is not supported for hotswap deployments', - ); + nonHotswappableResources.push(nonHotswappableResource(hotswappableChangeCandidate)); } } // resolve all detector results - const changesDetectionResults: Array = []; + const changesDetectionResults: Array = []; for (const detectorResultPromises of promises) { // Constant set of promises per resource // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism @@ -284,8 +282,8 @@ async function classifyResourceChanges( } return { - hotswapOperations: hotswappableResources, - nonHotswappableChanges: nonHotswappableResources, + hotswappable: hotswappableResources, + nonHotswappable: nonHotswappableResources, }; } @@ -348,19 +346,23 @@ async function findNestedHotswappableChanges( evaluateCfnTemplate: EvaluateCloudFormationTemplate, sdk: SDK, hotswapPropertyOverrides: HotswapPropertyOverrides, -): Promise { +): Promise { const nestedStack = nestedStackTemplates[logicalId]; if (!nestedStack.physicalName) { return { - hotswapOperations: [], - nonHotswappableChanges: [ + hotswappable: [], + nonHotswappable: [ { hotswappable: false, - logicalId, - reason: NonHotswappableReason.NESTED_STACK_CREATION, - description: `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', + change: { + reason: NonHotswappableReason.NESTED_STACK_CREATION, + description: 'newly created nested stacks cannot be hotswapped', + subject: { + type: 'Resource', + resourceType: 'AWS::CloudFormation::Stack', + logicalId, + }, + }, }, ], }; @@ -425,25 +427,34 @@ function makeRenameDifference( function isCandidateForHotswapping( change: cfn_diff.ResourceDifference, logicalId: string, -): HotswapOperation | NonHotswappableChange | ResourceChange { +): RejectedChange | ResourceChange { // 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, - logicalId, - rejectedChanges: [], - reason: NonHotswappableReason.RESOURCE_CREATION, - description: `resource '${logicalId}' was created by this deployment`, + change: { + reason: NonHotswappableReason.RESOURCE_CREATION, + description: `resource '${logicalId}' was created by this deployment`, + subject: { + type: 'Resource', + resourceType: change.newValue!.Type, + logicalId, + }, + }, }; } else if (!change.newValue) { return { hotswappable: false, - resourceType: change.oldValue!.Type, logicalId, - rejectedChanges: [], - reason: NonHotswappableReason.RESOURCE_DELETION, - description: `resource '${logicalId}' was destroyed by this deployment`, + change: { + reason: NonHotswappableReason.RESOURCE_DELETION, + description: `resource '${logicalId}' was destroyed by this deployment`, + subject: { + type: 'Resource', + resourceType: change.oldValue?.Type, + logicalId, + }, + }, }; } @@ -451,11 +462,15 @@ function isCandidateForHotswapping( if (change.newValue?.Type !== change.oldValue?.Type) { return { hotswappable: false, - resourceType: change.newValue?.Type, - logicalId, - rejectedChanges: [], - reason: NonHotswappableReason.RESOURCE_TYPE_CHANGED, - description: `resource '${logicalId}' had its type changed from '${change.oldValue?.Type}' to '${change.newValue?.Type}'`, + change: { + reason: NonHotswappableReason.RESOURCE_TYPE_CHANGED, + description: `resource '${logicalId}' had its type changed from '${change.oldValue?.Type}' to '${change.newValue?.Type}'`, + subject: { + type: 'Resource', + resourceType: change.newValue?.Type, + logicalId, + }, + }, }; } @@ -530,7 +545,7 @@ function formatWaiterErrorResult(result: WaiterResult) { async function logNonHotswappableChanges( ioSpan: IMessageSpan, - nonHotswappableChanges: NonHotswappableChange[], + nonHotswappableChanges: RejectedChange[], hotswapMode: HotswapMode, ): Promise { if (nonHotswappableChanges.length === 0) { @@ -559,25 +574,51 @@ async function logNonHotswappableChanges( messages.push(format('%s %s', chalk.red('⚠️'), chalk.red('The following non-hotswappable changes were found:'))); } - for (const change of nonHotswappableChanges) { - if (change.rejectedChanges.length > 0) { - messages.push(format( - ' logicalID: %s, type: %s, rejected changes: %s, reason: %s', - chalk.bold(change.logicalId), - chalk.bold(change.resourceType), - chalk.bold(change.rejectedChanges), - chalk.red(change.description), - )); - } else { - messages.push(format( - ' logicalID: %s, type: %s, reason: %s', - chalk.bold(change.logicalId), - chalk.bold(change.resourceType), - chalk.red(change.description), - )); - } + for (const rejection of nonHotswappableChanges) { + messages.push(' ' + nonHotswappableChangeMessage(rejection.change)); } messages.push(''); // newline await ioSpan.notify(IO.DEFAULT_TOOLKIT_INFO.msg(messages.join('\n'))); } + +/** + * Formats a NonHotswappableChange + */ +function nonHotswappableChangeMessage(change: NonHotswappableChange): string { + const subject = change.subject; + const reason = change.description ?? change.reason; + + switch (subject.type) { + case 'Output': + return format( + 'output: %s, reason: %s', + chalk.bold(subject.logicalId), + chalk.red(reason), + ); + case 'Resource': + return nonHotswappableResourceMessage(subject, reason); + } +} + +/** + * Formats a non-hotswappable resource subject + */ +function nonHotswappableResourceMessage(subject: ResourceSubject, reason: string): string { + if (subject.rejectedProperties?.length) { + return format( + 'resource: %s, type: %s, rejected changes: %s, reason: %s', + chalk.bold(subject.logicalId), + chalk.bold(subject.resourceType), + chalk.bold(subject.rejectedProperties), + chalk.red(reason), + ); + } + + return format( + 'resource: %s, type: %s, reason: %s', + chalk.bold(subject.logicalId), + chalk.bold(subject.resourceType), + chalk.red(reason), + ); +} 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 c2014c832..9c8ef74cf 100644 --- a/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts +++ b/packages/aws-cdk/lib/api/hotswap/appsync-mapping-templates.ts @@ -3,13 +3,12 @@ import type { GetSchemaCreationStatusCommandInput, } from '@aws-sdk/client-appsync'; import { - type ChangeHotswapResult, + type HotswapChange, classifyChanges, - lowerCaseFirstCharacter, - transformObjectKeys, } from './common'; import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; import { ToolkitError } from '../../toolkit/error'; +import { lowerCaseFirstCharacter, transformObjectKeys } from '../../util'; import type { SDK } from '../aws-auth'; import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; @@ -18,7 +17,7 @@ export async function isHotswappableAppSyncChange( logicalId: string, change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise { +): Promise { const isResolver = change.newValue.Type === 'AWS::AppSync::Resolver'; const isFunction = change.newValue.Type === 'AWS::AppSync::FunctionConfiguration'; const isGraphQLSchema = change.newValue.Type === 'AWS::AppSync::GraphQLSchema'; @@ -27,7 +26,7 @@ export async function isHotswappableAppSyncChange( return []; } - const ret: ChangeHotswapResult = []; + const ret: HotswapChange[] = []; const classifiedChanges = classifyChanges(change, [ 'RequestMappingTemplate', 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 659a3e557..a451c0917 100644 --- a/packages/aws-cdk/lib/api/hotswap/code-build-projects.ts +++ b/packages/aws-cdk/lib/api/hotswap/code-build-projects.ts @@ -1,11 +1,10 @@ import type { UpdateProjectCommandInput } from '@aws-sdk/client-codebuild'; import { - type ChangeHotswapResult, + type HotswapChange, classifyChanges, - lowerCaseFirstCharacter, - transformObjectKeys, } from './common'; import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; +import { lowerCaseFirstCharacter, transformObjectKeys } from '../../util'; import type { SDK } from '../aws-auth'; import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; @@ -13,12 +12,12 @@ export async function isHotswappableCodeBuildProjectChange( logicalId: string, change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise { +): Promise { if (change.newValue.Type !== 'AWS::CodeBuild::Project') { return []; } - const ret: ChangeHotswapResult = []; + const ret: HotswapChange[] = []; const classifiedChanges = classifyChanges(change, ['Source', 'Environment', 'SourceVersion']); classifiedChanges.reportNonHotswappablePropertyChanges(ret); diff --git a/packages/aws-cdk/lib/api/hotswap/common.ts b/packages/aws-cdk/lib/api/hotswap/common.ts index 3b7be7bec..f4eb5efe6 100644 --- a/packages/aws-cdk/lib/api/hotswap/common.ts +++ b/packages/aws-cdk/lib/api/hotswap/common.ts @@ -1,36 +1,11 @@ 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 type { HotswappableChange, NonHotswappableChange, ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; import { NonHotswappableReason } 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: HotswappableChange[]; - /** - * The changes that were deemed not hotswappable - */ - readonly nonHotswappableChanges: any[]; -} - export interface HotswapOperation { /** * Marks the operation as hotswappable @@ -54,20 +29,15 @@ export interface HotswapOperation { readonly apply: (sdk: SDK) => Promise; } -export interface NonHotswappableChange { - readonly hotswappable: false; - readonly resourceType: string; - readonly rejectedChanges: Array; - readonly logicalId: string; +export interface RejectedChange { /** - * Why was this change was deemed non-hotswappable + * Marks the change as not hotswappable */ - readonly reason: NonHotswappableReason; + readonly hotswappable: false; /** - * Tells the user exactly why this change was deemed non-hotswappable and what its logical ID is. - * If not specified, `displayReason` default to state that the properties listed in `rejectedChanges` are not hotswappable. + * The change that got rejected */ - readonly description?: string; + readonly change: NonHotswappableChange; /** * Whether or not to show this change when listing non-hotswappable changes in HOTSWAP_ONLY mode. Does not affect * listing in FALL_BACK mode. @@ -77,12 +47,7 @@ export interface NonHotswappableChange { readonly hotswapOnlyVisible?: boolean; } -export type ChangeHotswapResult = Array; - -export interface ClassifiedResourceChanges { - hotswapOperations: HotswapOperation[]; - nonHotswappableChanges: NonHotswappableChange[]; -} +export type HotswapChange = HotswapOperation | RejectedChange; export enum HotswapMode { /** @@ -101,8 +66,6 @@ export enum HotswapMode { FULL_DEPLOYMENT = 'full-deployment', } -type Exclude = { [key: string]: Exclude | true }; - /** * Represents configuration property overrides for hotswap deployments */ @@ -149,46 +112,9 @@ export class EcsHotswapProperties { } } -/** - * This function transforms all keys (recursively) in the provided `val` object. - * - * @param val The object whose keys need to be transformed. - * @param transform The function that will be applied to each key. - * @param exclude The keys that will not be transformed and copied to output directly - * @returns A new object with the same values as `val`, but with all keys transformed according to `transform`. - */ -export function transformObjectKeys(val: any, transform: (str: string) => string, exclude: Exclude = {}): any { - if (val == null || typeof val !== 'object') { - return val; - } - if (Array.isArray(val)) { - // For arrays we just pass parent's exclude object directly - // since it makes no sense to specify different exclude options for each array element - return val.map((input: any) => transformObjectKeys(input, transform, exclude)); - } - const ret: { [k: string]: any } = {}; - for (const [k, v] of Object.entries(val)) { - const childExclude = exclude[k]; - if (childExclude === true) { - // we don't transform this object if the key is specified in exclude - ret[transform(k)] = v; - } else { - ret[transform(k)] = transformObjectKeys(v, transform, childExclude); - } - } - return ret; -} - -/** - * This function lower cases the first character of the string provided. - */ -export function lowerCaseFirstCharacter(str: string): string { - return str.length > 0 ? `${str[0].toLowerCase()}${str.slice(1)}` : str; -} - type PropDiffs = Record>; -export class ClassifiedChanges { +class ClassifiedChanges { public constructor( public readonly change: ResourceChange, public readonly hotswappableProps: PropDiffs, @@ -196,20 +122,19 @@ export class ClassifiedChanges { ) { } - public reportNonHotswappablePropertyChanges(ret: ChangeHotswapResult): void { + public reportNonHotswappablePropertyChanges(ret: HotswapChange[]): void { 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 description = tagOnlyChange ? 'Tags are not hotswappable' : `resource properties '${nonHotswappablePropNames}' are not hotswappable on this resource type`; - reportNonHotswappableChange( - ret, + ret.push(nonHotswappableChange( this.change, reason, - this.nonHotswappableProps, description, - ); + this.nonHotswappableProps, + )); } } @@ -233,38 +158,41 @@ export function classifyChanges(xs: ResourceChange, hotswappablePropNames: strin return new ClassifiedChanges(xs, hotswappableProps, nonHotswappableProps); } -export function reportNonHotswappableChange( - ret: ChangeHotswapResult, +export function nonHotswappableChange( change: ResourceChange, reason: NonHotswappableReason, + description: string, nonHotswappableProps?: PropDiffs, - description?: string, hotswapOnlyVisible: boolean = true, -): void { - ret.push({ +): RejectedChange { + return { hotswappable: false, - rejectedChanges: Object.keys(nonHotswappableProps ?? change.propertyUpdates), - logicalId: change.logicalId, - resourceType: change.newValue.Type, - reason, - description: description, hotswapOnlyVisible, - }); + change: { + reason, + description, + subject: { + type: 'Resource', + logicalId: change.logicalId, + resourceType: change.newValue.Type, + rejectedProperties: Object.keys(nonHotswappableProps ?? change.propertyUpdates), + }, + }, + }; } -export function reportNonHotswappableResource( - change: ResourceChange, - reason: NonHotswappableReason, - description?: string, -): ChangeHotswapResult { - return [ - { - hotswappable: false, - rejectedChanges: Object.keys(change.propertyUpdates), - logicalId: change.logicalId, - resourceType: change.newValue.Type, - reason, - description: description, +export function nonHotswappableResource(change: ResourceChange): RejectedChange { + return { + hotswappable: false, + change: { + reason: NonHotswappableReason.RESOURCE_UNSUPPORTED, + description: 'This resource type is not supported for hotswap deployments', + subject: { + type: 'Resource', + logicalId: change.logicalId, + resourceType: change.newValue.Type, + rejectedProperties: Object.keys(change.propertyUpdates), + }, }, - ]; + }; } diff --git a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts index 595bb4947..76219afc2 100644 --- a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts +++ b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts @@ -1,13 +1,13 @@ import type { HotswapPropertyOverrides, - ChangeHotswapResult, + HotswapChange, } from './common'; import { - classifyChanges, lowerCaseFirstCharacter, - reportNonHotswappableChange, - transformObjectKeys, + classifyChanges, + nonHotswappableChange, } from './common'; import { NonHotswappableReason, type ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; +import { lowerCaseFirstCharacter, transformObjectKeys } from '../../util'; import type { SDK } from '../aws-auth'; import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; @@ -18,13 +18,13 @@ export async function isHotswappableEcsServiceChange( change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, hotswapPropertyOverrides: HotswapPropertyOverrides, -): Promise { +): Promise { // the only resource change we can evaluate here is an ECS TaskDefinition if (change.newValue.Type !== 'AWS::ECS::TaskDefinition') { return []; } - const ret: ChangeHotswapResult = []; + const ret: HotswapChange[] = []; // We only allow a change in the ContainerDefinitions of the TaskDefinition for now - // it contains the image and environment variables, so seems like a safe bet for now. @@ -55,27 +55,24 @@ export async function isHotswappableEcsServiceChange( * * This logic prevents us from logging that change as non-hotswappable when we hotswap it. */ - reportNonHotswappableChange( - ret, + ret.push(nonHotswappableChange( change, NonHotswappableReason.DEPENDENCY_UNSUPPORTED, - undefined, 'No ECS services reference the changed task definition', + undefined, 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 !== ECS_SERVICE_RESOURCE_TYPE); for (const taskRef of nonEcsServiceTaskDefRefs) { - reportNonHotswappableChange( - ret, + ret.push(nonHotswappableChange( 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}'`, - ); + )); } } diff --git a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts index 5adedf3d4..0cbec1906 100644 --- a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts +++ b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts @@ -1,7 +1,7 @@ import { Writable } from 'stream'; import type { PropertyDifference } from '@aws-cdk/cloudformation-diff'; import type { FunctionConfiguration, UpdateFunctionConfigurationCommandInput } from '@aws-sdk/client-lambda'; -import type { ChangeHotswapResult } from './common'; +import type { HotswapChange } from './common'; import { classifyChanges } from './common'; import type { AffectedResource, ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; import { ToolkitError } from '../../toolkit/error'; @@ -17,7 +17,7 @@ export async function isHotswappableLambdaFunctionChange( logicalId: string, change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise { +): Promise { // if the change is for a Lambda Version, we just ignore it // we will publish a new version when we get to hotswapping the actual Function this Version points to // (Versions can't be changed in CloudFormation anyway, they're immutable) @@ -35,7 +35,7 @@ export async function isHotswappableLambdaFunctionChange( return []; } - const ret: ChangeHotswapResult = []; + const ret: HotswapChange[] = []; const classifiedChanges = classifyChanges(change, ['Code', 'Environment', 'Description']); classifiedChanges.reportNonHotswappablePropertyChanges(ret); @@ -145,8 +145,8 @@ export async function isHotswappableLambdaFunctionChange( /** * Determines which changes to this Alias are hotswappable or not */ -function classifyAliasChanges(change: ResourceChange): ChangeHotswapResult { - const ret: ChangeHotswapResult = []; +function classifyAliasChanges(change: ResourceChange): HotswapChange[] { + const ret: HotswapChange[] = []; const classifiedChanges = classifyChanges(change, ['FunctionVersion']); classifiedChanges.reportNonHotswappablePropertyChanges(ret); 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 0a293bbe1..0dbc5e052 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,4 @@ -import type { ChangeHotswapResult } from './common'; +import type { HotswapChange } from './common'; import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; import type { SDK } from '../aws-auth'; import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; @@ -15,10 +15,10 @@ export async function isHotswappableS3BucketDeploymentChange( logicalId: string, change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise { +): 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 = []; + const ret: HotswapChange[] = []; if (change.newValue.Type !== CDK_BUCKET_DEPLOYMENT_CFN_TYPE) { return []; 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 5fa253d62..17fb6d4f4 100644 --- a/packages/aws-cdk/lib/api/hotswap/stepfunctions-state-machines.ts +++ b/packages/aws-cdk/lib/api/hotswap/stepfunctions-state-machines.ts @@ -1,4 +1,4 @@ -import { type ChangeHotswapResult, classifyChanges } from './common'; +import { type HotswapChange, classifyChanges } from './common'; import type { ResourceChange } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/hotswap'; import type { SDK } from '../aws-auth'; import type { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; @@ -7,11 +7,11 @@ export async function isHotswappableStateMachineChange( logicalId: string, change: ResourceChange, evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise { +): Promise { if (change.newValue.Type !== 'AWS::StepFunctions::StateMachine') { return []; } - const ret: ChangeHotswapResult = []; + const ret: HotswapChange[] = []; const classifiedChanges = classifyChanges(change, ['DefinitionString']); classifiedChanges.reportNonHotswappablePropertyChanges(ret); diff --git a/packages/aws-cdk/lib/legacy-exports-source.ts b/packages/aws-cdk/lib/legacy-exports-source.ts index 81c7b85a2..029517409 100644 --- a/packages/aws-cdk/lib/legacy-exports-source.ts +++ b/packages/aws-cdk/lib/legacy-exports-source.ts @@ -5,7 +5,7 @@ // Note: All type exports are in `legacy-exports.ts` export * from './legacy-logging-source'; -export { deepClone, flatten, ifDefined, isArray, isEmpty, numberFromBool, partition, padLeft as leftPad, contentHash, deepMerge } from './util'; +export { deepClone, flatten, ifDefined, isArray, isEmpty, numberFromBool, partition, padLeft as leftPad, contentHash, deepMerge, lowerCaseFirstCharacter } from './util'; export { deployStack } from './api/deployments/deploy-stack'; export { cli, exec } from './cli/cli'; export { SdkProvider } from './api/aws-auth'; @@ -19,7 +19,6 @@ export { RequireApproval } from './diff'; export { formatAsBanner } from './cli/util/console-formatters'; export { setSdkTracing as enableTracing } from './api/aws-auth/tracing'; export { aliases, command, describe } from './commands/docs'; -export { lowerCaseFirstCharacter } from './api/hotswap/common'; export { Deployments } from './api/deployments'; export { cliRootDir as rootDir } from './cli/root-dir'; export { latestVersionIfHigher, versionNumber } from './cli/version';