packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts (548 lines of code) (raw):
import { format } from 'util';
import type * as cxapi from '@aws-cdk/cx-api';
import type {
CreateChangeSetCommandInput,
CreateStackCommandInput,
DescribeChangeSetCommandOutput,
ExecuteChangeSetCommandInput,
UpdateStackCommandInput,
Tag,
} from '@aws-sdk/client-cloudformation';
import * as chalk from 'chalk';
import * as uuid from 'uuid';
import { AssetManifestBuilder } from './asset-manifest-builder';
import { publishAssets } from './asset-publishing';
import { addMetadataAssetsToManifest } from './assets';
import type {
ParameterValues,
ParameterChanges,
} from './cfn-api';
import {
changeSetHasNoChanges,
TemplateParameters,
waitForChangeSet,
waitForStackDeploy,
waitForStackDelete,
} from './cfn-api';
import { determineAllowCrossAccountAssetPublishing } from './checks';
import type { ChangeSetDeploymentMethod, DeploymentMethod } from './deployment-method';
import type { DeployStackResult, SuccessfulDeployStackResult } from './deployment-result';
import { formatErrorMessage } from '../../util';
import type { SDK, SdkProvider, ICloudFormationClient } from '../aws-auth/private';
import type { TemplateBodyParameter } from '../cloudformation';
import { makeBodyParameter, CfnEvaluationException, CloudFormationStack } from '../cloudformation';
import type { EnvironmentResources, StringWithoutPlaceholders } from '../environment';
import { HotswapMode, HotswapPropertyOverrides, ICON } from '../hotswap/common';
import { tryHotswapDeployment } from '../hotswap/hotswap-deployments';
import { IO, type IoHelper } from '../io/private';
import type { ResourcesToImport } from '../resource-import';
import { StackActivityMonitor } from '../stack-events';
import { ToolkitError } from '../toolkit-error';
export interface DeployStackOptions {
/**
* The stack to be deployed
*/
readonly stack: cxapi.CloudFormationStackArtifact;
/**
* The environment to deploy this stack in
*
* The environment on the stack artifact may be unresolved, this one
* must be resolved.
*/
readonly resolvedEnvironment: cxapi.Environment;
/**
* The SDK to use for deploying the stack
*
* Should have been initialized with the correct role with which
* stack operations should be performed.
*/
readonly sdk: SDK;
/**
* SDK provider (seeded with default credentials)
*
* Will be used to:
*
* - Publish assets, either legacy assets or large CFN templates
* that aren't themselves assets from a manifest. (Needs an SDK
* Provider because the file publishing role is declared as part
* of the asset).
* - Hotswap
*/
readonly sdkProvider: SdkProvider;
/**
* Information about the bootstrap stack found in the target environment
*/
readonly envResources: EnvironmentResources;
/**
* Role to pass to CloudFormation to execute the change set
*
* To obtain a `StringWithoutPlaceholders`, run a regular
* string though `TargetEnvironment.replacePlaceholders`.
*
* @default - No execution role; CloudFormation either uses the role currently associated with
* the stack, or otherwise uses current AWS credentials.
*/
readonly roleArn?: StringWithoutPlaceholders;
/**
* Notification ARNs to pass to CloudFormation to notify when the change set has completed
*
* @default - No notifications
*/
readonly notificationArns?: string[];
/**
* Name to deploy the stack under
*
* @default - Name from assembly
*/
readonly deployName?: string;
/**
* List of asset IDs which shouldn't be built
*
* @default - Build all assets
*/
readonly reuseAssets?: string[];
/**
* Tags to pass to CloudFormation to add to stack
*
* @default - No tags
*/
readonly tags?: Tag[];
/**
* What deployment method to use
*
* @default - Change set with defaults
*/
readonly deploymentMethod?: DeploymentMethod;
/**
* The collection of extra parameters
* (in addition to those used for assets)
* to pass to the deployed template.
* Note that parameters with `undefined` or empty values will be ignored,
* and not passed to the template.
*
* @default - no additional parameters will be passed to the template
*/
readonly parameters?: { [name: string]: string | undefined };
/**
* Use previous values for unspecified parameters
*
* If not set, all parameters must be specified for every deployment.
*
* @default false
*/
readonly usePreviousParameters?: boolean;
/**
* Deploy even if the deployed template is identical to the one we are about to deploy.
* @default false
*/
readonly forceDeployment?: boolean;
/**
* Rollback failed deployments
*
* @default true
*/
readonly rollback?: boolean;
/*
* Whether to perform a 'hotswap' deployment.
* A 'hotswap' deployment will attempt to short-circuit CloudFormation
* and update the affected resources like Lambda functions directly.
*
* @default - `HotswapMode.FULL_DEPLOYMENT` for regular deployments, `HotswapMode.HOTSWAP_ONLY` for 'watch' deployments
*/
readonly hotswap?: HotswapMode;
/**
* Extra properties that configure hotswap behavior
*/
readonly hotswapPropertyOverrides?: HotswapPropertyOverrides;
/**
* The extra string to append to the User-Agent header when performing AWS SDK calls.
*
* @default - nothing extra is appended to the User-Agent header
*/
readonly extraUserAgent?: string;
/**
* If set, change set of type IMPORT will be created, and resourcesToImport
* passed to it.
*/
readonly resourcesToImport?: ResourcesToImport;
/**
* If present, use this given template instead of the stored one
*
* @default - Use the stored template
*/
readonly overrideTemplate?: any;
/**
* Whether to build/publish assets in parallel
*
* @default true To remain backward compatible.
*/
readonly assetParallelism?: boolean;
}
export async function deployStack(options: DeployStackOptions, ioHelper: IoHelper): Promise<DeployStackResult> {
const stackArtifact = options.stack;
const stackEnv = options.resolvedEnvironment;
options.sdk.appendCustomUserAgent(options.extraUserAgent);
const cfn = options.sdk.cloudFormation();
const deployName = options.deployName || stackArtifact.stackName;
let cloudFormationStack = await CloudFormationStack.lookup(cfn, deployName);
if (cloudFormationStack.stackStatus.isCreationFailure) {
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 });
const deletedStack = await waitForStackDelete(cfn, ioHelper, deployName);
if (deletedStack && deletedStack.stackStatus.name !== 'DELETE_COMPLETE') {
throw new ToolkitError(
`Failed deleting stack ${deployName} that had previously failed creation (current state: ${deletedStack.stackStatus})`,
);
}
// Update variable to mark that the stack does not exist anymore, but avoid
// doing an actual lookup in CloudFormation (which would be silly to do if
// we just deleted it).
cloudFormationStack = CloudFormationStack.doesNotExist(cfn, deployName);
}
// Detect "legacy" assets (which remain in the metadata) and publish them via
// an ad-hoc asset manifest, while passing their locations via template
// parameters.
const legacyAssets = new AssetManifestBuilder();
const assetParams = await addMetadataAssetsToManifest(
ioHelper,
stackArtifact,
legacyAssets,
options.envResources,
options.reuseAssets,
);
const finalParameterValues = { ...options.parameters, ...assetParams };
const templateParams = TemplateParameters.fromTemplate(stackArtifact.template);
const stackParams = options.usePreviousParameters
? templateParams.updateExisting(finalParameterValues, cloudFormationStack.parameters)
: templateParams.supplyAll(finalParameterValues);
const hotswapMode = options.hotswap ?? HotswapMode.FULL_DEPLOYMENT;
const hotswapPropertyOverrides = options.hotswapPropertyOverrides ?? new HotswapPropertyOverrides();
if (await canSkipDeploy(options, cloudFormationStack, stackParams.hasChanges(cloudFormationStack.parameters), ioHelper)) {
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(IO.DEFAULT_TOOLKIT_INFO.msg(
format(
`\n ${ICON} %s\n`,
chalk.bold('hotswap deployment skipped - no changes were detected (use --force to override)'),
),
));
}
return {
type: 'did-deploy-stack',
noOp: true,
outputs: cloudFormationStack.outputs,
stackArn: cloudFormationStack.stackId,
};
} else {
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: deploying...`));
}
const bodyParameter = await makeBodyParameter(
ioHelper,
stackArtifact,
options.resolvedEnvironment,
legacyAssets,
options.envResources,
options.overrideTemplate,
);
let bootstrapStackName: string | undefined;
try {
bootstrapStackName = (await options.envResources.lookupToolkit()).stackName;
} catch (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,
allowCrossAccount: await determineAllowCrossAccountAssetPublishing(options.sdk, ioHelper, bootstrapStackName),
}, ioHelper);
if (hotswapMode !== HotswapMode.FULL_DEPLOYMENT) {
// attempt to short-circuit the deployment if possible
try {
const hotswapDeploymentResult = await tryHotswapDeployment(
options.sdkProvider,
ioHelper,
stackParams.values,
cloudFormationStack,
stackArtifact,
hotswapMode,
hotswapPropertyOverrides,
);
if (hotswapDeploymentResult) {
return hotswapDeploymentResult;
}
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,
)));
} catch (e) {
if (!(e instanceof CfnEvaluationException)) {
throw e;
}
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(IO.DEFAULT_TOOLKIT_INFO.msg('Falling back to doing a full deployment'));
options.sdk.appendCustomUserAgent('cdk-hotswap/fallback');
} else {
return {
type: 'did-deploy-stack',
noOp: true,
stackArn: cloudFormationStack.stackId,
outputs: cloudFormationStack.outputs,
};
}
}
// could not short-circuit the deployment, perform a full CFN deploy instead
const fullDeployment = new FullCloudFormationDeployment(
options,
cloudFormationStack,
stackArtifact,
stackParams,
bodyParameter,
ioHelper,
);
return fullDeployment.performDeployment();
}
type CommonPrepareOptions = keyof CreateStackCommandInput &
keyof UpdateStackCommandInput &
keyof CreateChangeSetCommandInput;
type CommonExecuteOptions = keyof CreateStackCommandInput &
keyof UpdateStackCommandInput &
keyof ExecuteChangeSetCommandInput;
/**
* This class shares state and functionality between the different full deployment modes
*/
class FullCloudFormationDeployment {
private readonly cfn: ICloudFormationClient;
private readonly stackName: string;
private readonly update: boolean;
private readonly verb: string;
private readonly uuid: string;
constructor(
private readonly options: DeployStackOptions,
private readonly cloudFormationStack: CloudFormationStack,
private readonly stackArtifact: cxapi.CloudFormationStackArtifact,
private readonly stackParams: ParameterValues,
private readonly bodyParameter: TemplateBodyParameter,
private readonly ioHelper: IoHelper,
) {
this.cfn = options.sdk.cloudFormation();
this.stackName = options.deployName ?? stackArtifact.stackName;
this.update = cloudFormationStack.exists && cloudFormationStack.stackStatus.name !== 'REVIEW_IN_PROGRESS';
this.verb = this.update ? 'update' : 'create';
this.uuid = uuid.v4();
}
public async performDeployment(): Promise<DeployStackResult> {
const deploymentMethod = this.options.deploymentMethod ?? {
method: 'change-set',
};
if (deploymentMethod.method === 'direct' && this.options.resourcesToImport) {
throw new ToolkitError('Importing resources requires a changeset deployment');
}
switch (deploymentMethod.method) {
case 'change-set':
return this.changeSetDeployment(deploymentMethod);
case 'direct':
return this.directDeployment();
}
}
private async changeSetDeployment(deploymentMethod: ChangeSetDeploymentMethod): Promise<DeployStackResult> {
const changeSetName = deploymentMethod.changeSetName ?? 'cdk-deploy-change-set';
const execute = deploymentMethod.execute ?? true;
const importExistingResources = deploymentMethod.importExistingResources ?? false;
const changeSetDescription = await this.createChangeSet(changeSetName, execute, importExistingResources);
await this.updateTerminationProtection();
if (changeSetHasNoChanges(changeSetDescription)) {
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(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Deleting empty change set %s', changeSetDescription.ChangeSetId)));
await this.cfn.deleteChangeSet({
StackName: this.stackName,
ChangeSetName: changeSetName,
});
}
if (this.options.forceDeployment) {
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.',
'',
'You cannot use the --force flag to get rid of changes you made in the console. Try using',
'CloudFormation drift detection instead: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-stack-drift.html',
].join('\n'),
));
}
return {
type: 'did-deploy-stack',
noOp: true,
outputs: this.cloudFormationStack.outputs,
stackArn: changeSetDescription.StackId!,
};
}
if (!execute) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format(
'Changeset %s created and waiting in review for manual execution (--no-execute)',
changeSetDescription.ChangeSetId,
)));
return {
type: 'did-deploy-stack',
noOp: false,
outputs: this.cloudFormationStack.outputs,
stackArn: changeSetDescription.StackId!,
};
}
// If there are replacements in the changeset, check the rollback flag and stack status
const replacement = hasReplacement(changeSetDescription);
const isPausedFailState = this.cloudFormationStack.stackStatus.isRollbackable;
const rollback = this.options.rollback ?? true;
if (isPausedFailState && replacement) {
return { type: 'failpaused-need-rollback-first', reason: 'replacement', status: this.cloudFormationStack.stackStatus.name };
}
if (isPausedFailState && rollback) {
return { type: 'failpaused-need-rollback-first', reason: 'not-norollback', status: this.cloudFormationStack.stackStatus.name };
}
if (!rollback && replacement) {
return { type: 'replacement-requires-rollback' };
}
return this.executeChangeSet(changeSetDescription);
}
private async createChangeSet(changeSetName: string, willExecute: boolean, importExistingResources: boolean) {
await this.cleanupOldChangeset(changeSetName);
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,
ChangeSetType: this.options.resourcesToImport ? 'IMPORT' : this.update ? 'UPDATE' : 'CREATE',
ResourcesToImport: this.options.resourcesToImport,
Description: `CDK Changeset for execution ${this.uuid}`,
ClientToken: `create${this.uuid}`,
ImportExistingResources: importExistingResources,
...this.commonPrepareOptions(),
});
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,
});
}
private async executeChangeSet(changeSet: DescribeChangeSetCommandOutput): Promise<SuccessfulDeployStackResult> {
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,
ChangeSetName: changeSet.ChangeSetName!,
ClientRequestToken: `exec${this.uuid}`,
...this.commonExecuteOptions(),
});
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,
this.stackName,
),
));
// +1 for the extra event emitted from updates.
const changeSetLength: number = (changeSet.Changes ?? []).length + (this.update ? 1 : 0);
return this.monitorDeployment(changeSet.CreationTime!, changeSetLength);
}
private async cleanupOldChangeset(changeSetName: string) {
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(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Removing existing change set with name ${changeSetName} if it exists`));
await this.cfn.deleteChangeSet({
StackName: this.stackName,
ChangeSetName: changeSetName,
});
}
}
private async updateTerminationProtection() {
// Update termination protection only if it has changed.
const terminationProtection = this.stackArtifact.terminationProtection ?? false;
if (!!this.cloudFormationStack.terminationProtection !== terminationProtection) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(
format (
'Updating termination protection from %s to %s for stack %s',
this.cloudFormationStack.terminationProtection,
terminationProtection,
this.stackName,
),
));
await this.cfn.updateTerminationProtection({
StackName: this.stackName,
EnableTerminationProtection: terminationProtection,
});
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<SuccessfulDeployStackResult> {
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();
if (this.update) {
await this.updateTerminationProtection();
try {
await this.cfn.updateStack({
StackName: this.stackName,
ClientRequestToken: `update${this.uuid}`,
...this.commonPrepareOptions(),
...this.commonExecuteOptions(),
});
} catch (err: any) {
if (err.message === 'No updates are to be performed.') {
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,
outputs: this.cloudFormationStack.outputs,
stackArn: this.cloudFormationStack.stackId,
};
}
throw err;
}
return this.monitorDeployment(startTime, undefined);
} else {
// Take advantage of the fact that we can set termination protection during create
const terminationProtection = this.stackArtifact.terminationProtection ?? false;
await this.cfn.createStack({
StackName: this.stackName,
ClientRequestToken: `create${this.uuid}`,
...(terminationProtection ? { EnableTerminationProtection: true } : undefined),
...this.commonPrepareOptions(),
...this.commonExecuteOptions(),
});
return this.monitorDeployment(startTime, undefined);
}
}
private async monitorDeployment(startTime: Date, expectedChanges: number | undefined): Promise<SuccessfulDeployStackResult> {
const monitor = new StackActivityMonitor({
cfn: this.cfn,
stack: this.stackArtifact,
stackName: this.stackName,
resourcesTotal: expectedChanges,
ioHelper: this.ioHelper,
changeSetCreationTime: startTime,
});
await monitor.start();
let finalState = this.cloudFormationStack;
try {
const successStack = await waitForStackDeploy(this.cfn, this.ioHelper, this.stackName);
// This shouldn't really happen, but catch it anyway. You never know.
if (!successStack) {
throw new ToolkitError('Stack deploy failed (the stack disappeared while we were deploying it)');
}
finalState = successStack;
} catch (e: any) {
throw new ToolkitError(suffixWithErrors(formatErrorMessage(e), monitor.errors));
} finally {
await monitor.stop();
}
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(format('Stack %s has completed updating', this.stackName)));
return {
type: 'did-deploy-stack',
noOp: false,
outputs: finalState.outputs,
stackArn: finalState.stackId,
};
}
/**
* Return the options that are shared between CreateStack, UpdateStack and CreateChangeSet
*/
private commonPrepareOptions(): Partial<Pick<UpdateStackCommandInput, CommonPrepareOptions>> {
return {
Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
NotificationARNs: this.options.notificationArns,
Parameters: this.stackParams.apiParameters,
RoleARN: this.options.roleArn,
TemplateBody: this.bodyParameter.TemplateBody,
TemplateURL: this.bodyParameter.TemplateURL,
Tags: this.options.tags,
};
}
/**
* Return the options that are shared between UpdateStack and CreateChangeSet
*
* Be careful not to add in keys for options that aren't used, as the features may not have been
* deployed everywhere yet.
*/
private commonExecuteOptions(): Partial<Pick<UpdateStackCommandInput, CommonExecuteOptions>> {
const shouldDisableRollback = this.options.rollback === false;
return {
StackName: this.stackName,
...(shouldDisableRollback ? { DisableRollback: true } : undefined),
};
}
}
export interface DestroyStackOptions {
/**
* The stack to be destroyed
*/
stack: cxapi.CloudFormationStackArtifact;
sdk: SDK;
roleArn?: string;
deployName?: string;
}
export interface DestroyStackResult {
/**
* The ARN of the stack that was destroyed, if any.
*
* If the stack didn't exist to begin with, the operation will succeed
* but this value will be undefined.
*/
readonly stackArn?: string;
}
export async function destroyStack(options: DestroyStackOptions, ioHelper: IoHelper): Promise<DestroyStackResult> {
const deployName = options.deployName || options.stack.stackName;
const cfn = options.sdk.cloudFormation();
const currentStack = await CloudFormationStack.lookup(cfn, deployName);
if (!currentStack.exists) {
return {};
}
const monitor = new StackActivityMonitor({
cfn,
stack: options.stack,
stackName: deployName,
ioHelper: ioHelper,
});
await monitor.start();
try {
await cfn.deleteStack({ StackName: deployName, RoleARN: options.roleArn });
const destroyedStack = await waitForStackDelete(cfn, ioHelper, deployName);
if (destroyedStack && destroyedStack.stackStatus.name !== 'DELETE_COMPLETE') {
throw new ToolkitError(`Failed to destroy ${deployName}: ${destroyedStack.stackStatus}`);
}
return { stackArn: currentStack.stackId };
} catch (e: any) {
throw new ToolkitError(suffixWithErrors(formatErrorMessage(e), monitor.errors));
} finally {
if (monitor) {
await monitor.stop();
}
}
}
/**
* Checks whether we can skip deployment
*
* We do this in a complicated way by preprocessing (instead of just
* looking at the changeset), because if there are nested stacks involved
* the changeset will always show the nested stacks as needing to be
* updated, and the deployment will take a long time to in effect not
* do anything.
*/
async function canSkipDeploy(
deployStackOptions: DeployStackOptions,
cloudFormationStack: CloudFormationStack,
parameterChanges: ParameterChanges,
ioHelper: IoHelper,
): Promise<boolean> {
const deployName = deployStackOptions.deployName || deployStackOptions.stack.stackName;
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: checking if we can skip deploy`));
// Forced deploy
if (deployStackOptions.forceDeployment) {
await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: forced deployment`));
return false;
}
// Creating changeset only (default true), never skip
if (
deployStackOptions.deploymentMethod?.method === 'change-set' &&
deployStackOptions.deploymentMethod.execute === false
) {
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(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(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: template has changed`));
return false;
}
// Tags have changed
if (!compareTags(cloudFormationStack.tags, deployStackOptions.tags ?? [])) {
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(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(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: termination protection has been updated`));
return false;
}
// Parameters have changed
if (parameterChanges) {
if (parameterChanges === 'ssm') {
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(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(IO.DEFAULT_TOOLKIT_DEBUG.msg(`${deployName}: stack is in a failure state`));
return false;
}
// We can skip deploy
return true;
}
/**
* Compares two list of tags, returns true if identical.
*/
function compareTags(a: Tag[], b: Tag[]): boolean {
if (a.length !== b.length) {
return false;
}
for (const aTag of a) {
const bTag = b.find((tag) => tag.Key === aTag.Key);
if (!bTag || bTag.Value !== aTag.Value) {
return false;
}
}
return true;
}
function suffixWithErrors(msg: string, errors?: string[]) {
return errors && errors.length > 0 ? `${msg}: ${errors.join(', ')}` : msg;
}
function arrayEquals(a: any[], b: any[]): boolean {
return a.every((item) => b.includes(item)) && b.every((item) => a.includes(item));
}
function hasReplacement(cs: DescribeChangeSetCommandOutput) {
return (cs.Changes ?? []).some(c => {
const a = c.ResourceChange?.PolicyAction;
return a === 'ReplaceAndDelete' || a === 'ReplaceAndRetain' || a === 'ReplaceAndSnapshot';
});
}