packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-deployments.ts (502 lines of code) (raw):
import { format } from 'util';
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, HotswapResult, ResourceSubject, ResourceChange, NonHotswappableChange } from '../../payloads';
import { NonHotswappableReason } from '../../payloads';
import { formatErrorMessage } from '../../util';
import type { SDK, SdkProvider } from '../aws-auth/private';
import type { CloudFormationStack, NestedStackTemplates } from '../cloudformation';
import { loadCurrentTemplateWithNestedStacks, EvaluateCloudFormationTemplate } from '../cloudformation';
import { isHotswappableAppSyncChange } from './appsync-mapping-templates';
import { isHotswappableCodeBuildProjectChange } from './code-build-projects';
import type {
HotswapChange,
HotswapOperation,
RejectedChange,
HotswapPropertyOverrides,
} from './common';
import {
ICON,
nonHotswappableResource,
} from './common';
import { isHotswappableEcsServiceChange } from './ecs-services';
import { isHotswappableLambdaFunctionChange } from './lambda-functions';
import {
skipChangeForS3DeployCustomResourcePolicy,
isHotswappableS3BucketDeploymentChange,
} from './s3-bucket-deployments';
import { isHotswappableStateMachineChange } from './stepfunctions-state-machines';
import type { SuccessfulDeployStackResult } from '../deployments';
import { IO, SPAN } from '../io/private';
import type { IMessageSpan, IoHelper } from '../io/private';
import { Mode } from '../plugin';
import { ToolkitError } from '../toolkit-error';
// 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
const pLimit: typeof import('p-limit') = require('p-limit');
type HotswapDetector = (
logicalId: string,
change: ResourceChange,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
hotswapPropertyOverrides: HotswapPropertyOverrides,
) => Promise<HotswapChange[]>;
type HotswapMode = 'hotswap-only' | 'fall-back';
const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = {
// Lambda
'AWS::Lambda::Function': isHotswappableLambdaFunctionChange,
'AWS::Lambda::Version': isHotswappableLambdaFunctionChange,
'AWS::Lambda::Alias': isHotswappableLambdaFunctionChange,
// AppSync
'AWS::AppSync::Resolver': isHotswappableAppSyncChange,
'AWS::AppSync::FunctionConfiguration': isHotswappableAppSyncChange,
'AWS::AppSync::GraphQLSchema': isHotswappableAppSyncChange,
'AWS::AppSync::ApiKey': isHotswappableAppSyncChange,
'AWS::ECS::TaskDefinition': isHotswappableEcsServiceChange,
'AWS::CodeBuild::Project': isHotswappableCodeBuildProjectChange,
'AWS::StepFunctions::StateMachine': isHotswappableStateMachineChange,
'Custom::CDKBucketDeployment': isHotswappableS3BucketDeploymentChange,
'AWS::IAM::Policy': async (
logicalId: string,
change: ResourceChange,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<HotswapChange[]> => {
// If the policy is for a S3BucketDeploymentChange, we can ignore the change
if (await skipChangeForS3DeployCustomResourcePolicy(logicalId, change, evaluateCfnTemplate)) {
return [];
}
return [nonHotswappableResource(change)];
},
'AWS::CDK::Metadata': async () => [],
};
/**
* 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`.
*/
export async function tryHotswapDeployment(
sdkProvider: SdkProvider,
ioHelper: IoHelper,
assetParams: { [key: string]: string },
cloudFormationStack: CloudFormationStack,
stackArtifact: cxapi.CloudFormationStackArtifact,
hotswapMode: HotswapMode,
hotswapPropertyOverrides: HotswapPropertyOverrides,
): Promise<SuccessfulDeployStackResult | undefined> {
const hotswapSpan = await ioHelper.span(SPAN.HOTSWAP).begin({
stack: stackArtifact,
mode: hotswapMode,
});
const result = await hotswapDeployment(
sdkProvider,
hotswapSpan,
assetParams,
stackArtifact,
hotswapMode,
hotswapPropertyOverrides,
);
await hotswapSpan.end(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.
* Returns information about the attempted hotswap deployment
*/
async function hotswapDeployment(
sdkProvider: SdkProvider,
ioSpan: IMessageSpan<any>,
assetParams: { [key: string]: string },
stack: cxapi.CloudFormationStackArtifact,
hotswapMode: HotswapMode,
hotswapPropertyOverrides: HotswapPropertyOverrides,
): Promise<Omit<HotswapResult, 'duration'>> {
// resolve the environment, so we can substitute things like AWS::Region in CFN expressions
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(stack, sdk);
const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({
stackArtifact: stack,
parameters: assetParams,
account: resolvedEnv.account,
region: resolvedEnv.region,
partition: (await sdk.currentAccount()).partition,
sdk,
nestedStacks: currentTemplate.nestedStacks,
});
const stackChanges = cfn_diff.fullDiff(currentTemplate.deployedRootTemplate, stack.template);
const { hotswappable, nonHotswappable } = await classifyResourceChanges(
stackChanges,
evaluateCfnTemplate,
sdk,
currentTemplate.nestedStacks, hotswapPropertyOverrides,
);
await logRejectedChanges(ioSpan, nonHotswappable, hotswapMode);
const hotswappableChanges = hotswappable.map(o => o.change);
const nonHotswappableChanges = nonHotswappable.map(n => n.change);
await ioSpan.notify(IO.CDK_TOOLKIT_I5401.msg('Hotswap plan created', {
stack,
mode: hotswapMode,
hotswappableChanges,
nonHotswappableChanges,
}));
// preserve classic hotswap behavior
if (hotswapMode === 'fall-back') {
if (nonHotswappableChanges.length > 0) {
return {
stack,
mode: hotswapMode,
hotswapped: false,
hotswappableChanges,
nonHotswappableChanges,
};
}
}
// apply the short-circuitable changes
await applyAllHotswapOperations(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.
*/
async function classifyResourceChanges(
stackChanges: cfn_diff.TemplateDiff,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
sdk: SDK,
nestedStackNames: { [nestedStackName: string]: NestedStackTemplates },
hotswapPropertyOverrides: HotswapPropertyOverrides,
): Promise<ClassifiedChanges> {
const resourceDifferences = getStackResourceDifferences(stackChanges);
const promises: Array<() => Promise<HotswapChange[]>> = [];
const hotswappableResources = new Array<HotswapOperation>();
const nonHotswappableResources = new Array<RejectedChange>();
for (const logicalId of Object.keys(stackChanges.outputs.changes)) {
nonHotswappableResources.push({
hotswappable: false,
change: {
reason: NonHotswappableReason.OUTPUT,
description: 'output was changed',
subject: {
type: 'Output',
logicalId,
metadata: evaluateCfnTemplate.metadataFor(logicalId),
},
},
});
}
// gather the results of the detector functions
for (const [logicalId, change] of Object.entries(resourceDifferences)) {
if (change.newValue?.Type === 'AWS::CloudFormation::Stack' && change.oldValue?.Type === 'AWS::CloudFormation::Stack') {
const nestedHotswappableResources = await findNestedHotswappableChanges(
logicalId,
change,
nestedStackNames,
evaluateCfnTemplate,
sdk,
hotswapPropertyOverrides,
);
hotswappableResources.push(...nestedHotswappableResources.hotswappable);
nonHotswappableResources.push(...nestedHotswappableResources.nonHotswappable);
continue;
}
const hotswappableChangeCandidate = isCandidateForHotswapping(logicalId, change, evaluateCfnTemplate);
// we don't need to run this through the detector functions, we can already judge this
if ('hotswappable' in hotswappableChangeCandidate) {
if (!hotswappableChangeCandidate.hotswappable) {
nonHotswappableResources.push(hotswappableChangeCandidate);
}
continue;
}
const resourceType: string = hotswappableChangeCandidate.newValue.Type;
if (resourceType in RESOURCE_DETECTORS) {
// run detector functions lazily to prevent unhandled promise rejections
promises.push(() =>
RESOURCE_DETECTORS[resourceType](logicalId, hotswappableChangeCandidate, evaluateCfnTemplate, hotswapPropertyOverrides),
);
} else {
nonHotswappableResources.push(nonHotswappableResource(hotswappableChangeCandidate));
}
}
// resolve all detector results
const changesDetectionResults: Array<HotswapChange[]> = [];
for (const detectorResultPromises of promises) {
// Constant set of promises per resource
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
const hotswapDetectionResults = await Promise.all(await detectorResultPromises());
changesDetectionResults.push(hotswapDetectionResults);
}
for (const resourceDetectionResults of changesDetectionResults) {
for (const propertyResult of resourceDetectionResults) {
propertyResult.hotswappable
? hotswappableResources.push(propertyResult)
: nonHotswappableResources.push(propertyResult);
}
}
return {
hotswappable: hotswappableResources,
nonHotswappable: nonHotswappableResources,
};
}
/**
* Returns all changes to resources in the given Stack.
*
* @param stackChanges the collection of all changes to a given Stack
*/
function getStackResourceDifferences(stackChanges: cfn_diff.TemplateDiff): {
[logicalId: string]: cfn_diff.ResourceDifference;
} {
// we need to collapse logical ID rename changes into one change,
// as they are represented in stackChanges as a pair of two changes: one addition and one removal
const allResourceChanges: { [logId: string]: cfn_diff.ResourceDifference } = stackChanges.resources.changes;
const allRemovalChanges = filterDict(allResourceChanges, (resChange) => resChange.isRemoval);
const allNonRemovalChanges = filterDict(allResourceChanges, (resChange) => !resChange.isRemoval);
for (const [logId, nonRemovalChange] of Object.entries(allNonRemovalChanges)) {
if (nonRemovalChange.isAddition) {
const addChange = nonRemovalChange;
// search for an identical removal change
const identicalRemovalChange = Object.entries(allRemovalChanges).find(([_, remChange]) => {
return changesAreForSameResource(remChange, addChange);
});
// if we found one, then this means this is a rename change
if (identicalRemovalChange) {
const [removedLogId, removedResourceChange] = identicalRemovalChange;
allNonRemovalChanges[logId] = makeRenameDifference(removedResourceChange, addChange);
// delete the removal change that forms the rename pair
delete allRemovalChanges[removedLogId];
}
}
}
// the final result are all of the remaining removal changes,
// plus all of the non-removal changes
// (we saved the rename changes in that object already)
return {
...allRemovalChanges,
...allNonRemovalChanges,
};
}
/** Filters an object with string keys based on whether the callback returns 'true' for the given value in the object. */
function filterDict<T>(dict: { [key: string]: T }, func: (t: T) => boolean): { [key: string]: T } {
return Object.entries(dict).reduce(
(acc, [key, t]) => {
if (func(t)) {
acc[key] = t;
}
return acc;
},
{} as { [key: string]: T },
);
}
/** Finds any hotswappable changes in all nested stacks. */
async function findNestedHotswappableChanges(
logicalId: string,
change: cfn_diff.ResourceDifference,
nestedStackTemplates: { [nestedStackName: string]: NestedStackTemplates },
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
sdk: SDK,
hotswapPropertyOverrides: HotswapPropertyOverrides,
): Promise<ClassifiedChanges> {
const nestedStack = nestedStackTemplates[logicalId];
if (!nestedStack.physicalName) {
return {
hotswappable: [],
nonHotswappable: [
{
hotswappable: false,
change: {
reason: NonHotswappableReason.NESTED_STACK_CREATION,
description: 'newly created nested stacks cannot be hotswapped',
subject: {
type: 'Resource',
logicalId,
resourceType: 'AWS::CloudFormation::Stack',
metadata: evaluateCfnTemplate.metadataFor(logicalId),
},
},
},
],
};
}
const evaluateNestedCfnTemplate = await evaluateCfnTemplate.createNestedEvaluateCloudFormationTemplate(
nestedStack.physicalName,
nestedStack.generatedTemplate,
change.newValue?.Properties?.Parameters,
);
const nestedDiff = cfn_diff.fullDiff(
nestedStackTemplates[logicalId].deployedTemplate,
nestedStackTemplates[logicalId].generatedTemplate,
);
return classifyResourceChanges(
nestedDiff,
evaluateNestedCfnTemplate,
sdk,
nestedStackTemplates[logicalId].nestedStackTemplates,
hotswapPropertyOverrides,
);
}
/** Returns 'true' if a pair of changes is for the same resource. */
function changesAreForSameResource(
oldChange: cfn_diff.ResourceDifference,
newChange: cfn_diff.ResourceDifference,
): boolean {
return (
oldChange.oldResourceType === newChange.newResourceType &&
// this isn't great, but I don't want to bring in something like underscore just for this comparison
JSON.stringify(oldChange.oldProperties) === JSON.stringify(newChange.newProperties)
);
}
function makeRenameDifference(
remChange: cfn_diff.ResourceDifference,
addChange: cfn_diff.ResourceDifference,
): cfn_diff.ResourceDifference {
return new cfn_diff.ResourceDifference(
// we have to fill in the old value, because otherwise this will be classified as a non-hotswappable change
remChange.oldValue,
addChange.newValue,
{
resourceType: {
oldType: remChange.oldResourceType,
newType: addChange.newResourceType,
},
propertyDiffs: (addChange as any).propertyDiffs,
otherDiffs: (addChange as any).otherDiffs,
},
);
}
/**
* Returns a `HotswappableChangeCandidate` if the change is hotswappable
* Returns an empty `HotswappableChange` if the change is to CDK::Metadata
* Returns a `NonHotswappableChange` if the change is not hotswappable
*/
function isCandidateForHotswapping(
logicalId: string,
change: cfn_diff.ResourceDifference,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): 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,
change: {
reason: NonHotswappableReason.RESOURCE_CREATION,
description: `resource '${logicalId}' was created by this deployment`,
subject: {
type: 'Resource',
logicalId,
resourceType: change.newValue!.Type,
metadata: evaluateCfnTemplate.metadataFor(logicalId),
},
},
};
} else if (!change.newValue) {
return {
hotswappable: false,
logicalId,
change: {
reason: NonHotswappableReason.RESOURCE_DELETION,
description: `resource '${logicalId}' was destroyed by this deployment`,
subject: {
type: 'Resource',
logicalId,
resourceType: change.oldValue.Type,
metadata: evaluateCfnTemplate.metadataFor(logicalId),
},
},
};
}
// a resource has had its type changed
if (change.newValue.Type !== change.oldValue.Type) {
return {
hotswappable: false,
change: {
reason: NonHotswappableReason.RESOURCE_TYPE_CHANGED,
description: `resource '${logicalId}' had its type changed from '${change.oldValue?.Type}' to '${change.newValue?.Type}'`,
subject: {
type: 'Resource',
logicalId,
resourceType: change.newValue.Type,
metadata: evaluateCfnTemplate.metadataFor(logicalId),
},
},
};
}
return {
logicalId,
oldValue: change.oldValue,
newValue: change.newValue,
propertyUpdates: change.propertyUpdates,
metadata: evaluateCfnTemplate.metadataFor(logicalId),
};
}
async function applyAllHotswapOperations(sdk: SDK, ioSpan: IMessageSpan<any>, hotswappableChanges: HotswapOperation[]): Promise<void[]> {
if (hotswappableChanges.length === 0) {
return Promise.resolve([]);
}
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 applyHotswapOperation(sdk, ioSpan, hotswapOperation);
})));
}
async function applyHotswapOperation(sdk: SDK, ioSpan: IMessageSpan<any>, hotswapOperation: HotswapOperation): Promise<void> {
// note the type of service that was successfully hotswapped in the User-Agent
const customUserAgent = `cdk-hotswap/success-${hotswapOperation.service}`;
sdk.appendCustomUserAgent(customUserAgent);
const resourceText = (r: AffectedResource) => r.description ?? `${r.resourceType} '${r.physicalName ?? r.logicalId}'`;
await ioSpan.notify(IO.CDK_TOOLKIT_I5402.msg(
hotswapOperation.change.resources.map(r => format(` ${ICON} %s`, chalk.bold(resourceText(r)))).join('\n'),
hotswapOperation.change,
));
// if the SDK call fails, an error will be thrown by the SDK
// and will prevent the green 'hotswapped!' text from being displayed
try {
await hotswapOperation.apply(sdk);
} catch (e: any) {
if (e.name === 'TimeoutError' || e.name === 'AbortError') {
const result: WaiterResult = JSON.parse(formatErrorMessage(e));
const error = new ToolkitError(formatWaiterErrorResult(result));
error.name = e.name;
throw error;
}
throw e;
}
await ioSpan.notify(IO.CDK_TOOLKIT_I5403.msg(
hotswapOperation.change.resources.map(r => format(` ${ICON} %s %s`, chalk.bold(resourceText(r)), chalk.green('hotswapped!'))).join('\n'),
hotswapOperation.change,
));
sdk.removeCustomUserAgent(customUserAgent);
}
function formatWaiterErrorResult(result: WaiterResult) {
const main = [
`Resource is not in the expected state due to waiter status: ${result.state}`,
result.reason ? `${result.reason}.` : '',
].join('. ');
if (result.observedResponses != null) {
const observedResponses = Object
.entries(result.observedResponses)
.map(([msg, count]) => ` - ${msg} (${count})`)
.join('\n');
return `${main} Observed responses:\n${observedResponses}`;
}
return main;
}
async function logRejectedChanges(
ioSpan: IMessageSpan<any>,
rejectedChanges: RejectedChange[],
hotswapMode: HotswapMode,
): Promise<void> {
if (rejectedChanges.length === 0) {
return;
}
/**
* 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.
*
* This logic prevents us from logging that change as non-hotswappable when we hotswap it.
*/
if (hotswapMode === 'hotswap-only') {
rejectedChanges = rejectedChanges.filter((change) => change.hotswapOnlyVisible === true);
if (rejectedChanges.length === 0) {
return;
}
}
const messages = ['']; // start with empty line
if (hotswapMode === 'hotswap-only') {
messages.push(format('%s %s', chalk.red('⚠️'), chalk.red('The following non-hotswappable changes were found. To reconcile these using CloudFormation, specify --hotswap-fallback')));
} else {
messages.push(format('%s %s', chalk.red('⚠️'), chalk.red('The following non-hotswappable changes were found:')));
}
for (const { change } of rejectedChanges) {
messages.push(' ' + nonHotswappableChangeMessage(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),
);
}