Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,98 @@ export interface BaseDeployOptions {
readonly stackEventPollingInterval?: number;
}

/**
* The resource type of a rollback trigger.
*
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_RollbackTrigger.html
*/
export enum RollbackTriggerType {
/**
* A CloudWatch metric alarm (`AWS::CloudWatch::Alarm`).
*/
ALARM = 'AWS::CloudWatch::Alarm',

/**
* A CloudWatch composite alarm (`AWS::CloudWatch::CompositeAlarm`).
*/
COMPOSITE_ALARM = 'AWS::CloudWatch::CompositeAlarm',
}

/**
* A CloudWatch alarm that CloudFormation monitors during a stack operation.
*
* If the alarm goes into the `ALARM` state during deployment (or during the
* monitoring period afterwards), CloudFormation rolls the operation back.
*/
export interface RollbackTrigger {
/**
* The ARN of the CloudWatch alarm that CloudFormation monitors.
*/
readonly arn: string;

/**
* The resource type of the alarm identified by `arn`.
*
* A metric alarm and a composite alarm cannot be told apart from their ARN,
* so the type must be stated explicitly when using composite alarms.
*
* @default RollbackTriggerType.ALARM
*/
readonly type?: RollbackTriggerType;
}

/**
* Configuration of CloudFormation rollback triggers.
*
* Rollback triggers let CloudFormation monitor the state of your application
* during and after a stack create or update, and roll the operation back if any
* of the specified CloudWatch alarms breach.
*
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-rollback-triggers.html
*/
export interface RollbackConfiguration {
/**
* The CloudWatch alarms that CloudFormation monitors during the stack operation.
*
* A maximum of 5 triggers can be specified.
*
* @default - No rollback triggers
*/
readonly triggers?: RollbackTrigger[];

/**
* The amount of time, in minutes, during which CloudFormation should monitor
* all the rollback triggers after the stack operation reaches its complete
* state.
*
* If no value is specified, the default is 0 minutes (CloudFormation monitors
* the triggers only while the stack operation is in progress). The maximum
* value is 180 minutes.
*
* @default - CloudFormation default (0 minutes)
*/
readonly monitoringTimeInMinutes?: number;
}

export interface DeployOptions extends BaseDeployOptions {
/**
* ARNs of SNS topics that CloudFormation will notify with stack related events
*/
readonly notificationArns?: string[];

/**
* CloudFormation rollback triggers to monitor during the deployment.
*
* Following the same semantics as `notificationArns`:
*
* - `undefined`: CDK ignores it (allows external management).
* - `{ triggers: [] }`: CDK manages it and clears any existing triggers.
* - `{ triggers: [...] }`: CDK sets the triggers to the provided list.
*
* @default - Rollback configuration is not managed by CDK
*/
readonly rollbackConfiguration?: RollbackConfiguration;

/**
* Tags to pass to CloudFormation for deployment
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Stack, Tag } from '@aws-sdk/client-cloudformation';
import type { RollbackConfiguration, Stack, Tag } from '@aws-sdk/client-cloudformation';
import { ToolkitError } from '../../toolkit/toolkit-error';
import { formatErrorMessage, deserializeStructure } from '../../util';
import type { ICloudFormationClient } from '../aws-auth/private';
Expand Down Expand Up @@ -151,6 +151,15 @@ export class CloudFormationStack {
return this.stack?.NotificationARNs ?? [];
}

/**
* Rollback configuration (rollback triggers and monitoring time) currently applied to the stack.
*
* Empty configuration if the stack does not exist or has no rollback configuration.
*/
public get rollbackConfiguration(): RollbackConfiguration {
return this.stack?.RollbackConfiguration ?? {};
}

/**
* Return the names of all current parameters to the stack
*
Expand Down
40 changes: 40 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
CreateStackCommandInput,
ExecuteChangeSetCommandInput,
UpdateStackCommandInput,
RollbackConfiguration,
Tag,
DeploymentConfig,
} from '@aws-sdk/client-cloudformation';
Expand Down Expand Up @@ -105,6 +106,13 @@ export interface DeployStackOptions {
*/
readonly notificationArns?: string[];

/**
* Rollback configuration (rollback triggers and monitoring time) to pass to CloudFormation
*
* @default - Rollback configuration is not managed by CDK
*/
readonly rollbackConfiguration?: RollbackConfiguration;

/**
* Name to deploy the stack under
*
Expand Down Expand Up @@ -783,6 +791,7 @@ class FullCloudFormationDeployment {
return {
Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
NotificationARNs: this.options.notificationArns,
RollbackConfiguration: this.options.rollbackConfiguration,
Parameters: this.stackParams.apiParameters,
RoleARN: this.options.roleArn,
TemplateBody: this.bodyParameter.TemplateBody,
Expand Down Expand Up @@ -944,6 +953,15 @@ async function canSkipDeploy(
return false;
}

// Rollback configuration has changed. `undefined` means CDK does not manage it, so leave it alone.
if (
deployStackOptions.rollbackConfiguration !== undefined &&
!rollbackConfigurationEquals(cloudFormationStack.rollbackConfiguration, deployStackOptions.rollbackConfiguration)
) {
await ioHelper.defaults.debug(`${deployName}: rollback configuration has changed`);
return false;
}

// Termination protection has been updated
if (!!deployStackOptions.stack.terminationProtection !== !!cloudFormationStack.terminationProtection) {
await ioHelper.defaults.debug(`${deployName}: termination protection has been updated`);
Expand Down Expand Up @@ -1008,6 +1026,28 @@ function arrayEquals(a: any[], b: any[]): boolean {
return a.every((item) => b.includes(item)) && b.every((item) => a.includes(item));
}

/**
* Compare the rollback configuration currently deployed on the stack with the desired one.
*
* Triggers are compared order-independently by (Arn, Type). A missing monitoring time is
* treated as the CloudFormation default of 0 minutes.
*/
function rollbackConfigurationEquals(deployed: RollbackConfiguration, desired: RollbackConfiguration): boolean {
if ((deployed.MonitoringTimeInMinutes ?? 0) !== (desired.MonitoringTimeInMinutes ?? 0)) {
return false;
}

const deployedTriggers = deployed.RollbackTriggers ?? [];
const desiredTriggers = desired.RollbackTriggers ?? [];
if (deployedTriggers.length !== desiredTriggers.length) {
return false;
}

const key = (t: { Arn?: string; Type?: string }) => `${t.Arn}|${t.Type}`;
const deployedKeys = new Set(deployedTriggers.map(key));
return desiredTriggers.every((trigger) => deployedKeys.has(key(trigger)));
}

function hasReplacement(report: ChangeSetReport) {
return (report.changeSet.Changes ?? []).some(c => {
const a = c.ResourceChange?.PolicyAction;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import * as cdk_assets from '@aws-cdk/cdk-assets-lib';
import type * as cxapi from '@aws-cdk/cloud-assembly-api';
import type { RollbackConfiguration } from '@aws-sdk/client-cloudformation';
import chalk from 'chalk';
import { AssetManifestBuilder } from './asset-manifest-builder';
import {
Expand Down Expand Up @@ -64,6 +65,13 @@ export interface DeployStackOptions {
*/
readonly notificationArns?: string[];

/**
* Rollback configuration (rollback triggers and monitoring time) to pass through to CloudFormation
*
* @default - Rollback configuration is not managed by CDK
*/
readonly rollbackConfiguration?: RollbackConfiguration;

/**
* Override name under which stack will be deployed
*
Expand Down Expand Up @@ -414,6 +422,7 @@ export class Deployments {
resolvedEnvironment: env.resolvedEnvironment,
deployName: options.deployName,
notificationArns: options.notificationArns,
rollbackConfiguration: options.rollbackConfiguration,
sdk: env.sdk,
sdkProvider: this.deployStackSdkProvider,
roleArn: executionRoleArn,
Expand Down
10 changes: 9 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ import type { AssetBuildNode, AssetPublishNode, Concurrency, StackNode } from '.
import { WorkGraph, WorkGraphBuilder, buildDestroyWorkGraph } from '../api/work-graph';
import type { AssemblyData, RefactorResult, StackDetails, SuccessfulDeployStackResult } from '../payloads';
import { PermissionChangeType } from '../payloads';
import { formatErrorMessage, formatExpressStabilizationWarning, formatTime, obscureTemplate, serializeStructure, validateSnsTopicArn } from '../util';
import { formatErrorMessage, formatExpressStabilizationWarning, formatTime, obscureTemplate, serializeStructure, toCloudFormationRollbackConfiguration, validateSnsTopicArn } from '../util';
import { pLimit } from '../util/concurrency';
import { createIgnoreMatcher } from '../util/glob-matcher';
import { promiseWithResolvers } from '../util/promises';
Expand Down Expand Up @@ -885,6 +885,13 @@ export class Toolkit extends CloudAssemblySourceBuilder {
}
}

// Rollback triggers follow the same semantics as Notification ARNs:
//
// - undefined => cdk ignores it, leaving any existing triggers in place.
// - { triggers: [] } => cdk manages it, clearing any existing triggers.
// - { triggers: [...] } => cdk sets the triggers to the provided list.
const rollbackConfiguration = toCloudFormationRollbackConfiguration(options.rollbackConfiguration);

// Deploy options that are shared between change set creation and execution
const sharedDeployOptions = {
stack,
Expand All @@ -898,6 +905,7 @@ export class Toolkit extends CloudAssemblySourceBuilder {
usePreviousParameters: options.parameters?.keepExistingParameters,
rollback: options.rollback,
notificationArns,
rollbackConfiguration,
extraUserAgent: options.extraUserAgent,
assetParallelism: options.assetParallelism,
express: options.express,
Expand Down
69 changes: 68 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/util/cloudformation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { StackEvent } from '@aws-sdk/client-cloudformation';
import type { RollbackConfiguration as CfnRollbackConfiguration, StackEvent } from '@aws-sdk/client-cloudformation';
import type { RollbackConfiguration } from '../actions/deploy';
import { RollbackTriggerType } from '../actions/deploy';
import { ToolkitError } from '../toolkit/toolkit-error';

/**
* Validate SNS topic arn
Expand All @@ -7,6 +10,70 @@ export function validateSnsTopicArn(arn: string): boolean {
return /^arn:aws:sns:[a-z0-9\-]+:[0-9]+:[a-z0-9\-\_]+$/i.test(arn);
}

/**
* Validate a CloudWatch alarm arn (metric or composite alarm)
*/
export function validateCloudWatchAlarmArn(arn: string): boolean {
return /^arn:[a-z0-9\-]+:cloudwatch:[a-z0-9\-]+:[0-9]+:alarm:.+$/i.test(arn);
}

/**
* The maximum number of rollback triggers CloudFormation accepts on a stack operation.
*/
const MAX_ROLLBACK_TRIGGERS = 5;

/**
* The maximum monitoring time, in minutes, CloudFormation accepts.
*/
const MAX_MONITORING_TIME_IN_MINUTES = 180;

/**
* Validate the user-provided rollback configuration and convert it to the shape
* expected by the CloudFormation SDK.
*
* Returns `undefined` when no configuration was provided, in which case CDK does
* not manage rollback triggers (any triggers previously set on the stack are
* left untouched by CloudFormation).
*/
export function toCloudFormationRollbackConfiguration(config?: RollbackConfiguration): CfnRollbackConfiguration | undefined {
if (config === undefined) {
return undefined;
}

const triggers = config.triggers ?? [];
if (triggers.length > MAX_ROLLBACK_TRIGGERS) {
throw new ToolkitError(
'InvalidRollbackConfiguration',
`a maximum of ${MAX_ROLLBACK_TRIGGERS} rollback triggers can be specified, got ${triggers.length}`,
);
}

for (const trigger of triggers) {
if (!validateCloudWatchAlarmArn(trigger.arn)) {
throw new ToolkitError(
'InvalidRollbackConfiguration',
`rollback trigger arn ${trigger.arn} is not a valid CloudWatch alarm arn`,
);
}
}

const monitoringTime = config.monitoringTimeInMinutes;
if (monitoringTime !== undefined && (monitoringTime < 0 || monitoringTime > MAX_MONITORING_TIME_IN_MINUTES)) {
throw new ToolkitError(
'InvalidRollbackConfiguration',
`monitoring time must be between 0 and ${MAX_MONITORING_TIME_IN_MINUTES} minutes, got ${monitoringTime}`,
);
}

return {
RollbackTriggers: triggers.map((trigger) => ({
Arn: trigger.arn,
Type: trigger.type ?? RollbackTriggerType.ALARM,
})),
MonitoringTimeInMinutes: monitoringTime,
};
}

/**
* Does a Stack Event have an error message based on the status.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type StackSummary,
type Change,
type Parameter,
type RollbackConfiguration,
type Tag,
type ServiceInputTypes,
type ServiceOutputTypes,
Expand Down Expand Up @@ -136,6 +137,7 @@ interface InMemoryStack {
tags: Tag[];
capabilities: string[];
notificationArns: string[];
rollbackConfiguration?: RollbackConfiguration;
outputs: { OutputKey: string; OutputValue: string }[];
enableTerminationProtection: boolean;
roleArn?: string;
Expand Down Expand Up @@ -307,6 +309,7 @@ export class FakeCloudFormation {
capabilities: (input.Capabilities as string[]) ?? [],
outputs: [],
notificationArns: input.NotificationARNs ?? [],
rollbackConfiguration: input.RollbackConfiguration,
enableTerminationProtection: input.EnableTerminationProtection ?? false,
roleArn: input.RoleARN,
creationTime: input.CreationTime ?? new Date(),
Expand Down Expand Up @@ -1181,6 +1184,7 @@ export class FakeCloudFormation {
Capabilities: stack.capabilities as any,
Outputs: stack.outputs.map((o) => ({ OutputKey: o.OutputKey, OutputValue: o.OutputValue })),
NotificationARNs: stack.notificationArns,
RollbackConfiguration: stack.rollbackConfiguration,
EnableTerminationProtection: stack.enableTerminationProtection,
RoleARN: stack.roleArn,
};
Expand Down
Loading
Loading