From 03370980cb0bb912f96e29a83655173a151763a6 Mon Sep 17 00:00:00 2001 From: mattmb Date: Sat, 22 Aug 2026 14:32:58 +0000 Subject: [PATCH] feat(toolkit-lib): support CloudFormation rollback triggers Expose CloudFormation rollback triggers (rollback alarms + monitoring time) so a stack create/update is automatically rolled back when a CloudWatch alarm breaches. - toolkit-lib: new public `rollbackConfiguration` deploy option (RollbackConfiguration/RollbackTrigger/RollbackTriggerType). It is validated (<=5 triggers, valid alarm ARNs, 0-180 min) and threaded through to the Create/Update/CreateChangeSet calls, with change detection so an unchanged config does not force a deployment. - cli: new `cdk deploy --rollback-trigger-alarm-arns` and `--monitoring-time-minutes` flags. CLI ARNs default to metric alarms; composite alarms are reachable via the programmatic API. Follows the same undefined/[]/[...] management semantics as notificationArns. Fixes aws/aws-cdk#5170 By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --- .../toolkit-lib/lib/actions/deploy/index.ts | 86 ++++++++++++++++ .../lib/api/cloudformation/stack-helpers.ts | 11 ++- .../lib/api/deployments/deploy-stack.ts | 40 ++++++++ .../lib/api/deployments/deployments.ts | 9 ++ .../toolkit-lib/lib/toolkit/toolkit.ts | 10 +- .../toolkit-lib/lib/util/cloudformation.ts | 69 ++++++++++++- .../_helpers/fake-aws/fake-cloudformation.ts | 4 + .../test/api/deployments/deploy-stack.test.ts | 82 ++++++++++++++++ .../test/util/cloudformation.test.ts | 98 ++++++++++++++++++- packages/aws-cdk/lib/api-private.ts | 1 + packages/aws-cdk/lib/cli/cdk-toolkit.ts | 19 +++- packages/aws-cdk/lib/cli/cli-config.ts | 2 + .../aws-cdk/lib/cli/cli-type-registry.json | 8 ++ packages/aws-cdk/lib/cli/cli.ts | 15 +++ .../aws-cdk/lib/cli/convert-to-user-input.ts | 4 + .../lib/cli/parse-command-line-arguments.ts | 11 +++ packages/aws-cdk/lib/cli/user-input.ts | 14 +++ .../aws-cdk/test/cli/cli-arguments.test.ts | 2 + .../aws-cdk/test/cli/deploy-options.test.ts | 40 ++++++++ ...id_rollback_trigger_arn_is_rejected.ndjson | 5 + ...verted_and_forwarded_to_deployStack.ndjson | 13 +++ packages/aws-cdk/test/commands/deploy.test.ts | 34 +++++++ 22 files changed, 571 insertions(+), 6 deletions(-) create mode 100644 packages/aws-cdk/test/commands/__io_snapshots__/deploy/deploy_parameters_forwarded_to_CloudFormation_an_invalid_rollback_trigger_arn_is_rejected.ndjson create mode 100644 packages/aws-cdk/test/commands/__io_snapshots__/deploy/deploy_parameters_forwarded_to_CloudFormation_rollback_triggers_are_converted_and_forwarded_to_deployStack.ndjson diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts index d5a25051a..13949c535 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts @@ -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 */ diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/stack-helpers.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/stack-helpers.ts index 271258a26..83be79878 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/stack-helpers.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/stack-helpers.ts @@ -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'; @@ -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 * diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts index 6719b3d3a..e2b104db0 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts @@ -7,6 +7,7 @@ import type { CreateStackCommandInput, ExecuteChangeSetCommandInput, UpdateStackCommandInput, + RollbackConfiguration, Tag, DeploymentConfig, } from '@aws-sdk/client-cloudformation'; @@ -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 * @@ -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, @@ -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`); @@ -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; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts index f94f9f124..ca239fef4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts @@ -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 { @@ -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 * @@ -419,6 +427,7 @@ export class Deployments { resolvedEnvironment: env.resolvedEnvironment, deployName: options.deployName, notificationArns: options.notificationArns, + rollbackConfiguration: options.rollbackConfiguration, sdk: env.sdk, sdkProvider: this.deployStackSdkProvider, roleArn: executionRoleArn, diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 726cb41d0..afedbc3fd 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -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'; @@ -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, @@ -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, diff --git a/packages/@aws-cdk/toolkit-lib/lib/util/cloudformation.ts b/packages/@aws-cdk/toolkit-lib/lib/util/cloudformation.ts index 41fc658a8..a7de15f35 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/util/cloudformation.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/util/cloudformation.ts @@ -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 @@ -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 && (!Number.isInteger(monitoringTime) || monitoringTime < 0 || monitoringTime > MAX_MONITORING_TIME_IN_MINUTES)) { + throw new ToolkitError( + 'InvalidRollbackConfiguration', + `monitoring time must be a whole number 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. */ diff --git a/packages/@aws-cdk/toolkit-lib/test/_helpers/fake-aws/fake-cloudformation.ts b/packages/@aws-cdk/toolkit-lib/test/_helpers/fake-aws/fake-cloudformation.ts index 3a208bd4d..8efa48748 100644 --- a/packages/@aws-cdk/toolkit-lib/test/_helpers/fake-aws/fake-cloudformation.ts +++ b/packages/@aws-cdk/toolkit-lib/test/_helpers/fake-aws/fake-cloudformation.ts @@ -38,6 +38,7 @@ import { type StackSummary, type Change, type Parameter, + type RollbackConfiguration, type Tag, type ServiceInputTypes, type ServiceOutputTypes, @@ -136,6 +137,7 @@ interface InMemoryStack { tags: Tag[]; capabilities: string[]; notificationArns: string[]; + rollbackConfiguration?: RollbackConfiguration; outputs: { OutputKey: string; OutputValue: string }[]; enableTerminationProtection: boolean; roleArn?: string; @@ -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(), @@ -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, }; diff --git a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts index cd86a95ef..a65b92282 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts @@ -677,6 +677,88 @@ test('deploy is not skipped if notificationArns are different', async () => { expect(mockCloudFormationClient).toHaveReceivedCommand(CreateChangeSetCommand); }); +test('rollback configuration is passed through to CloudFormation', async () => { + // GIVEN + givenStackExists(); + givenTemplateIs(FAKE_STACK.template); + + // WHEN + await testDeployStack({ + ...standardDeployStackArguments(FAKE_STACK), + rollbackConfiguration: { + RollbackTriggers: [ + { Arn: 'arn:aws:cloudwatch:bermuda-triangle-1337:123456789012:alarm:MyAlarm', Type: 'AWS::CloudWatch::Alarm' }, + ], + MonitoringTimeInMinutes: 10, + }, + }); + + // THEN + expect(mockCloudFormationClient).toHaveReceivedCommandWith(CreateChangeSetCommand, { + ...expect.anything, + RollbackConfiguration: { + RollbackTriggers: [ + { Arn: 'arn:aws:cloudwatch:bermuda-triangle-1337:123456789012:alarm:MyAlarm', Type: 'AWS::CloudWatch::Alarm' }, + ], + MonitoringTimeInMinutes: 10, + }, + } as CreateChangeSetCommandInput); +}); + +test('deploy is skipped if rollback configuration is the same', async () => { + // GIVEN + givenStackExists({ + RollbackConfiguration: { + RollbackTriggers: [ + { Arn: 'arn:aws:cloudwatch:bermuda-triangle-1337:123456789012:alarm:MyAlarm', Type: 'AWS::CloudWatch::Alarm' }, + ], + MonitoringTimeInMinutes: 10, + }, + }); + givenTemplateIs(FAKE_STACK.template); + + // WHEN + await testDeployStack({ + ...standardDeployStackArguments(FAKE_STACK), + rollbackConfiguration: { + RollbackTriggers: [ + { Arn: 'arn:aws:cloudwatch:bermuda-triangle-1337:123456789012:alarm:MyAlarm', Type: 'AWS::CloudWatch::Alarm' }, + ], + MonitoringTimeInMinutes: 10, + }, + }); + + // THEN + expect(mockCloudFormationClient).not.toHaveReceivedCommand(CreateChangeSetCommand); +}); + +test('deploy is not skipped if rollback configuration is different', async () => { + // GIVEN + givenStackExists({ + RollbackConfiguration: { + RollbackTriggers: [ + { Arn: 'arn:aws:cloudwatch:bermuda-triangle-1337:123456789012:alarm:MyAlarm', Type: 'AWS::CloudWatch::Alarm' }, + ], + MonitoringTimeInMinutes: 10, + }, + }); + givenTemplateIs(FAKE_STACK.template); + + // WHEN + await testDeployStack({ + ...standardDeployStackArguments(FAKE_STACK), + rollbackConfiguration: { + RollbackTriggers: [ + { Arn: 'arn:aws:cloudwatch:bermuda-triangle-1337:123456789012:alarm:MyAlarm', Type: 'AWS::CloudWatch::Alarm' }, + ], + MonitoringTimeInMinutes: 30, + }, + }); + + // THEN + expect(mockCloudFormationClient).toHaveReceivedCommand(CreateChangeSetCommand); +}); + test('deploy is not skipped if we are deploying and there is a change from the last hotswap deployment', async () => { // GIVEN // The synthesized template matches what CloudFormation currently has stored, so every normal diff --git a/packages/@aws-cdk/toolkit-lib/test/util/cloudformation.test.ts b/packages/@aws-cdk/toolkit-lib/test/util/cloudformation.test.ts index 9ceb584ab..10f5e81d5 100644 --- a/packages/@aws-cdk/toolkit-lib/test/util/cloudformation.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/util/cloudformation.test.ts @@ -1,5 +1,7 @@ import type { StackEvent } from '@aws-sdk/client-cloudformation'; -import { validateSnsTopicArn, maxResourceTypeLength, isErrorEvent, stackNameFromArn, changeSetNameFromArn } from '../../lib/util/cloudformation'; +import { RollbackTriggerType } from '../../lib/actions/deploy'; +import { ToolkitError } from '../../lib/toolkit/toolkit-error'; +import { validateSnsTopicArn, validateCloudWatchAlarmArn, toCloudFormationRollbackConfiguration, maxResourceTypeLength, isErrorEvent, stackNameFromArn, changeSetNameFromArn } from '../../lib/util/cloudformation'; describe('validateSnsTopicArn', () => { test('empty string', () => { @@ -28,6 +30,100 @@ describe('validateSnsTopicArn', () => { }); }); +describe('validateCloudWatchAlarmArn', () => { + test('empty string is invalid', () => { + expect(validateCloudWatchAlarmArn('')).toEqual(false); + }); + + test('an SNS topic arn is not a valid alarm arn', () => { + expect(validateCloudWatchAlarmArn('arn:aws:sns:eu-west-1:123456789012:foo')).toEqual(false); + }); + + test('a metric alarm arn is valid', () => { + expect(validateCloudWatchAlarmArn('arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm')).toEqual(true); + }); + + test('an alarm arn with special characters in the name is valid', () => { + expect(validateCloudWatchAlarmArn('arn:aws:cloudwatch:us-east-1:123456789012:alarm:My-Alarm_1 with spaces')).toEqual(true); + }); + + test('gov-cloud partition alarm arn is valid', () => { + expect(validateCloudWatchAlarmArn('arn:aws-us-gov:cloudwatch:us-gov-west-1:123456789012:alarm:MyAlarm')).toEqual(true); + }); + + test('an arn without an alarm name is invalid', () => { + expect(validateCloudWatchAlarmArn('arn:aws:cloudwatch:us-east-1:123456789012:alarm:')).toEqual(false); + }); +}); + +describe('toCloudFormationRollbackConfiguration', () => { + test('returns undefined when no configuration is provided', () => { + expect(toCloudFormationRollbackConfiguration(undefined)).toBeUndefined(); + }); + + test('an empty configuration clears triggers', () => { + expect(toCloudFormationRollbackConfiguration({})).toEqual({ + RollbackTriggers: [], + MonitoringTimeInMinutes: undefined, + }); + }); + + test('defaults the trigger type to a metric alarm', () => { + expect(toCloudFormationRollbackConfiguration({ + triggers: [{ arn: 'arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm' }], + monitoringTimeInMinutes: 15, + })).toEqual({ + RollbackTriggers: [ + { Arn: 'arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm', Type: 'AWS::CloudWatch::Alarm' }, + ], + MonitoringTimeInMinutes: 15, + }); + }); + + test('honours an explicit composite alarm type', () => { + expect(toCloudFormationRollbackConfiguration({ + triggers: [{ + arn: 'arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyComposite', + type: RollbackTriggerType.COMPOSITE_ALARM, + }], + })).toEqual({ + RollbackTriggers: [ + { Arn: 'arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyComposite', Type: 'AWS::CloudWatch::CompositeAlarm' }, + ], + MonitoringTimeInMinutes: undefined, + }); + }); + + test('throws when more than 5 triggers are specified', () => { + const triggers = Array.from({ length: 6 }, (_, i) => ({ + arn: `arn:aws:cloudwatch:us-east-1:123456789012:alarm:Alarm${i}`, + })); + expect(() => toCloudFormationRollbackConfiguration({ triggers })).toThrow(ToolkitError); + expect(() => toCloudFormationRollbackConfiguration({ triggers })).toThrow(/maximum of 5 rollback triggers/); + }); + + test('throws on an invalid alarm arn', () => { + expect(() => toCloudFormationRollbackConfiguration({ + triggers: [{ arn: 'arn:aws:sns:us-east-1:123456789012:not-an-alarm' }], + })).toThrow(/not a valid CloudWatch alarm arn/); + }); + + test.each([-1, 181])('throws when monitoring time %d is out of range', (monitoringTimeInMinutes) => { + expect(() => toCloudFormationRollbackConfiguration({ monitoringTimeInMinutes })).toThrow(/monitoring time must be a whole number between 0 and 180/); + }); + + test.each([12.5, NaN])('throws when monitoring time %d is not a whole number', (monitoringTimeInMinutes) => { + expect(() => toCloudFormationRollbackConfiguration({ monitoringTimeInMinutes })).toThrow(/monitoring time must be a whole number between 0 and 180/); + }); + + test.each([0, 180])('accepts monitoring time %d at the range boundary', (monitoringTimeInMinutes) => { + expect(toCloudFormationRollbackConfiguration({ monitoringTimeInMinutes })).toEqual({ + RollbackTriggers: [], + MonitoringTimeInMinutes: monitoringTimeInMinutes, + }); + }); +}); + describe('stackEventHasErrorMessage', () => { test('returns true for statuses ending with _FAILED', () => { expect(stackEventHasErrorMessage('CREATE_FAILED')).toBe(true); diff --git a/packages/aws-cdk/lib/api-private.ts b/packages/aws-cdk/lib/api-private.ts index 2a2d8106d..d2520f96a 100644 --- a/packages/aws-cdk/lib/api-private.ts +++ b/packages/aws-cdk/lib/api-private.ts @@ -7,6 +7,7 @@ export { Diagnosis } from '../../@aws-cdk/toolkit-lib/lib/api/diagnosing/diagnos export type { ChangeSetReport } from '../../@aws-cdk/toolkit-lib/lib/api/change-sets'; export { createIgnoreMatcher } from '../../@aws-cdk/toolkit-lib/lib/util/glob-matcher'; export { formatExpressStabilizationWarning } from '../../@aws-cdk/toolkit-lib/lib/util/cfn-express'; +export { toCloudFormationRollbackConfiguration } from '../../@aws-cdk/toolkit-lib/lib/util/cloudformation'; export * from '../../@aws-cdk/toolkit-lib/lib/api/io/private'; export * from '../../@aws-cdk/toolkit-lib/lib/api/tags/private'; export * from '../../@aws-cdk/toolkit-lib/lib/private/activity-printer'; diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 04bca0bf9..0dd5e6537 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -4,7 +4,7 @@ import { format } from 'node:util'; import type { IManifestEntry } from '@aws-cdk/cdk-assets-lib'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; import { RequireApproval } from '@aws-cdk/cloud-assembly-schema'; -import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, StackSelector, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib'; +import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, RollbackConfiguration, StackSelector, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib'; import { PermissionChangeType, Toolkit, ToolkitError, AbortError } from '@aws-cdk/toolkit-lib'; import chalk from 'chalk'; import * as chokidar from 'chokidar'; @@ -14,7 +14,7 @@ import { CliIoHost, suppressMessages } from './io-host'; import type { Configuration } from './user-configuration'; import { PROJECT_CONFIG } from './user-configuration'; import type { ActionLessRequest, IMessageSpan, IoHelper } from '../../lib/api-private'; -import { asIoHelper, cfnApi, createIgnoreMatcher, formatExpressStabilizationWarning, IO, tagsForStack, throwIfValidationFailures } from '../../lib/api-private'; +import { asIoHelper, cfnApi, createIgnoreMatcher, formatExpressStabilizationWarning, IO, tagsForStack, throwIfValidationFailures, toCloudFormationRollbackConfiguration } from '../../lib/api-private'; import type { AssetBuildNode, AssetPublishNode, Concurrency, MarkerNode, StackNode, WorkGraph, WorkGraphActions } from '../api'; import { CloudWatchLogEventMonitor, @@ -494,6 +494,7 @@ export class CdkToolkit { concurrency: options.concurrency, traceLogs: options.traceLogs, notificationArns: options.notificationArns, + rollbackConfiguration: options.rollbackConfiguration, tags: options.tags, outputsFile: options.outputsFile, assetParallelism: options.assetParallelism, @@ -575,6 +576,7 @@ export class CdkToolkit { force: options.force, stackCount: stackCollection.stackCount, notificationArns: options.notificationArns, + rollbackConfiguration: options.rollbackConfiguration, deploymentMethod: options.deploymentMethod, toolkitStackName: this.toolkitStackName, reuseAssets: options.reuseAssets, @@ -1715,6 +1717,13 @@ export interface DeployOptions extends CfnDeployOptions, WatchOptions { */ notificationArns?: string[]; + /** + * CloudFormation rollback triggers to monitor during the deployment + * + * @default - Rollback configuration is not managed by CDK + */ + rollbackConfiguration?: RollbackConfiguration; + /** * What kind of security changes require approval * @@ -2215,6 +2224,7 @@ interface WorkGraphDeploymentActionsOptions { readonly force?: boolean; readonly stackCount?: number; readonly notificationArns?: string[]; + readonly rollbackConfiguration?: RollbackConfiguration; readonly deploymentMethod?: DeploymentMethod; readonly toolkitStackName?: string; readonly reuseAssets?: string[]; @@ -2366,6 +2376,10 @@ class WorkGraphDeploymentActions implements WorkGraphActions { } } + // Validate and convert the rollback configuration to the shape CloudFormation expects. + // Undefined leaves any existing rollback triggers on the stack untouched. + const rollbackConfiguration = toCloudFormationRollbackConfiguration(this.options.rollbackConfiguration); + const parameterMap = buildParameterMap(this.options.parameters); // Deploy options that are shared between change set creation and execution @@ -2381,6 +2395,7 @@ class WorkGraphDeploymentActions implements WorkGraphActions { usePreviousParameters: this.options.usePreviousParameters, rollback: this.options.rollback, notificationArns, + rollbackConfiguration, extraUserAgent: this.options.extraUserAgent, assetParallelism: this.options.assetParallelism, express: this.options.express, diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index a071e003f..8706a4303 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -150,6 +150,8 @@ export async function makeConfig(): Promise { 'exclusively': { type: 'boolean', alias: 'e', desc: 'Only deploy requested stacks, don\'t include dependencies' }, 'require-approval': { type: 'string', choices: [RequireApproval.NEVER, RequireApproval.ANYCHANGE, RequireApproval.BROADENING], desc: 'What changes require manual approval' }, 'notification-arns': { type: 'array', desc: 'ARNs of SNS topics that CloudFormation will notify with stack related events. These will be added to ARNs specified with the \'notificationArns\' stack property.' }, + 'rollback-trigger-alarm-arns': { type: 'array', desc: 'ARNs of CloudWatch alarms that CloudFormation monitors during the deployment. If any alarm goes to ALARM state the deployment is rolled back. A maximum of 5 can be specified. Alarms are treated as metric alarms (AWS::CloudWatch::Alarm).' }, + 'monitoring-time-minutes': { type: 'number', desc: 'The number of minutes CloudFormation continues to monitor the rollback trigger alarms after the deployment completes, before cleaning up old resources. Must be a whole number between 0 and 180. Must be used together with --rollback-trigger-alarm-arns.' }, // @deprecated(v2) -- tags are part of the Cloud Assembly and tags specified here will be overwritten on the next deployment 'tags': { type: 'array', alias: 't', desc: 'Tags to add to the stack (KEY=VALUE), overrides tags from Cloud Assembly (deprecated)' }, 'execute': { type: 'boolean', desc: 'Whether to execute the change set (--no-execute will NOT execute the change set) (deprecated)', deprecated: true }, diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 9d7194d9f..838dc96d4 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -472,6 +472,14 @@ "type": "array", "desc": "ARNs of SNS topics that CloudFormation will notify with stack related events. These will be added to ARNs specified with the 'notificationArns' stack property." }, + "rollback-trigger-alarm-arns": { + "type": "array", + "desc": "ARNs of CloudWatch alarms that CloudFormation monitors during the deployment. If any alarm goes to ALARM state the deployment is rolled back. A maximum of 5 can be specified. Alarms are treated as metric alarms (AWS::CloudWatch::Alarm)." + }, + "monitoring-time-minutes": { + "type": "number", + "desc": "The number of minutes CloudFormation continues to monitor the rollback trigger alarms after the deployment completes, before cleaning up old resources. Must be a whole number between 0 and 180. Must be used together with --rollback-trigger-alarm-arns." + }, "tags": { "type": "array", "alias": "t", diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 75c0dca23..17f90f981 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -432,6 +432,20 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise ({ arn })), + monitoringTimeInMinutes: args.monitoringTimeMinutes, + } + : undefined; + return cli.deploy({ selector: { ...expandUp(mustMatch(explicitOrDefaultStacks(args.STACKS, args.all)), args.exclusively), @@ -440,6 +454,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { nargs: 1, requiresArg: true, }) + .option('rollback-trigger-alarm-arns', { + type: 'array', + desc: 'ARNs of CloudWatch alarms that CloudFormation monitors during the deployment. If any alarm goes to ALARM state the deployment is rolled back. A maximum of 5 can be specified. Alarms are treated as metric alarms (AWS::CloudWatch::Alarm).', + nargs: 1, + requiresArg: true, + }) + .option('monitoring-time-minutes', { + default: undefined, + type: 'number', + desc: 'The number of minutes CloudFormation continues to monitor the rollback trigger alarms after the deployment completes, before cleaning up old resources. Must be a whole number between 0 and 180. Must be used together with --rollback-trigger-alarm-arns.', + }) .option('tags', { type: 'array', alias: 't', diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 688635f5b..c4e0d6ab0 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -822,6 +822,20 @@ export interface DeployOptions { */ readonly notificationArns?: Array; + /** + * ARNs of CloudWatch alarms that CloudFormation monitors during the deployment. If any alarm goes to ALARM state the deployment is rolled back. A maximum of 5 can be specified. Alarms are treated as metric alarms (AWS::CloudWatch::Alarm). + * + * @default - undefined + */ + readonly rollbackTriggerAlarmArns?: Array; + + /** + * The number of minutes CloudFormation continues to monitor the rollback trigger alarms after the deployment completes, before cleaning up old resources. Must be a whole number between 0 and 180. Must be used together with --rollback-trigger-alarm-arns. + * + * @default - undefined + */ + readonly monitoringTimeMinutes?: number; + /** * Tags to add to the stack (KEY=VALUE), overrides tags from Cloud Assembly (deprecated) * diff --git a/packages/aws-cdk/test/cli/cli-arguments.test.ts b/packages/aws-cdk/test/cli/cli-arguments.test.ts index 51a56da05..3395edf5e 100644 --- a/packages/aws-cdk/test/cli/cli-arguments.test.ts +++ b/packages/aws-cdk/test/cli/cli-arguments.test.ts @@ -64,6 +64,7 @@ describe('yargs', () => { importExistingResources: false, logs: true, method: undefined, + monitoringTimeMinutes: undefined, notificationArns: undefined, outputsFile: undefined, parameters: [{}], @@ -72,6 +73,7 @@ describe('yargs', () => { requireApproval: undefined, revertDrift: false, rollback: false, + rollbackTriggerAlarmArns: undefined, tags: undefined, toolkitStackName: undefined, watch: undefined, diff --git a/packages/aws-cdk/test/cli/deploy-options.test.ts b/packages/aws-cdk/test/cli/deploy-options.test.ts index bc89065ad..636c8875f 100644 --- a/packages/aws-cdk/test/cli/deploy-options.test.ts +++ b/packages/aws-cdk/test/cli/deploy-options.test.ts @@ -65,6 +65,46 @@ describe('deploy --method=execute-change-set', () => { }); }); +describe('deploy rollback triggers', () => { + test('--rollback-trigger-alarm-arns builds a rollback configuration', async () => { + await exec(['deploy', '--app', 'echo', '--rollback-trigger-alarm-arns', 'arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm', 'MyStack']); + + expect(deploySpy).toHaveBeenCalledWith(expect.objectContaining({ + rollbackConfiguration: { + triggers: [{ arn: 'arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm' }], + monitoringTimeInMinutes: undefined, + }, + })); + }); + + test('--monitoring-time-minutes is carried alongside the alarm arns', async () => { + await exec(['deploy', '--app', 'echo', '--rollback-trigger-alarm-arns', 'arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm', '--monitoring-time-minutes', '30', 'MyStack']); + + expect(deploySpy).toHaveBeenCalledWith(expect.objectContaining({ + rollbackConfiguration: { + triggers: [{ arn: 'arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm' }], + monitoringTimeInMinutes: 30, + }, + })); + }); + + test('neither flag leaves the rollback configuration unmanaged', async () => { + await exec(['deploy', '--app', 'echo', 'MyStack']); + + expect(deploySpy).toHaveBeenCalledWith(expect.objectContaining({ + rollbackConfiguration: undefined, + })); + }); + + test('--monitoring-time-minutes without --rollback-trigger-alarm-arns is rejected', async () => { + await expect( + exec(['deploy', '--app', 'echo', '--monitoring-time-minutes', '30', 'MyStack']), + ).rejects.toThrow('--monitoring-time-minutes requires --rollback-trigger-alarm-arns'); + + expect(deploySpy).not.toHaveBeenCalled(); + }); +}); + describe('deploy --express', () => { test('passes express: true to CdkToolkit.deploy', async () => { await exec(['deploy', '--app', 'echo', '--express', 'MyStack']); diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/deploy/deploy_parameters_forwarded_to_CloudFormation_an_invalid_rollback_trigger_arn_is_rejected.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/deploy_parameters_forwarded_to_CloudFormation_an_invalid_rollback_trigger_arn_is_rejected.ndjson new file mode 100644 index 000000000..edb46921e --- /dev/null +++ b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/deploy_parameters_forwarded_to_CloudFormation_an_invalid_rollback_trigger_arn_is_rejected.ndjson @@ -0,0 +1,5 @@ +{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"info","code":null,"message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"deploy","level":"debug","code":null,"message":"Checking for previously published assets"} +{"seq":4,"type":"notify","action":"deploy","level":"debug","code":null,"message":"0 total assets, 0 still need to be published"} diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/deploy/deploy_parameters_forwarded_to_CloudFormation_rollback_triggers_are_converted_and_forwarded_to_deployStack.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/deploy_parameters_forwarded_to_CloudFormation_rollback_triggers_are_converted_and_forwarded_to_deployStack.ndjson new file mode 100644 index 000000000..d7765b76e --- /dev/null +++ b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/deploy_parameters_forwarded_to_CloudFormation_rollback_triggers_are_converted_and_forwarded_to_deployStack.ndjson @@ -0,0 +1,13 @@ +{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"info","code":null,"message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"deploy","level":"debug","code":null,"message":"Checking for previously published assets"} +{"seq":4,"type":"notify","action":"deploy","level":"debug","code":null,"message":"0 total assets, 0 still need to be published"} +{"seq":5,"type":"notify","action":"deploy","level":"info","code":null,"message":"Test-Stack-A-Display-Name: deploying... [1/1]"} +{"seq":6,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I3000","message":"Starting Deploy ..."} +{"seq":7,"type":"notify","action":"deploy","level":"info","code":null,"message":"\n✅ Test-Stack-A-Display-Name"} +{"seq":8,"type":"notify","action":"deploy","level":"info","code":null,"message":"\n✨ Deployment time: \n"} +{"seq":9,"type":"notify","action":"deploy","level":"info","code":null,"message":"Stack ARN:"} +{"seq":10,"type":"notify","action":"deploy","level":"result","code":null,"message":"arn:aws:cloudformation:bermuda-triangle-1:123456789012:stack/Test-Stack-A/abcd"} +{"seq":11,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I3001","message":"\n✨ Deploy time: \n"} +{"seq":12,"type":"notify","action":"deploy","level":"info","code":null,"message":"\n✨ Total time: \n"} diff --git a/packages/aws-cdk/test/commands/deploy.test.ts b/packages/aws-cdk/test/commands/deploy.test.ts index 27e67c8fe..54cfe3d20 100644 --- a/packages/aws-cdk/test/commands/deploy.test.ts +++ b/packages/aws-cdk/test/commands/deploy.test.ts @@ -484,6 +484,40 @@ describe('deploy parameters forwarded to CloudFormation', () => { ); }); + test('rollback triggers are converted and forwarded to deployStack', async () => { + await toolkit.deploy({ + selector: selectExact('Test-Stack-A-Display-Name'), + deploymentMethod: { method: 'change-set' }, + requireApproval: RequireApproval.NEVER, + rollbackConfiguration: { + triggers: [{ arn: 'arn:aws:cloudwatch:bermuda-triangle-1:123456789012:alarm:MyAlarm' }], + monitoringTimeInMinutes: 10, + }, + }); + + expect(cloudFormation.deployStack).toHaveBeenCalledWith( + expect.objectContaining({ + rollbackConfiguration: { + RollbackTriggers: [ + { Arn: 'arn:aws:cloudwatch:bermuda-triangle-1:123456789012:alarm:MyAlarm', Type: 'AWS::CloudWatch::Alarm' }, + ], + MonitoringTimeInMinutes: 10, + }, + }), + ); + }); + + test('an invalid rollback trigger arn is rejected', async () => { + await expect(toolkit.deploy({ + selector: selectExact('Test-Stack-A-Display-Name'), + deploymentMethod: { method: 'change-set' }, + requireApproval: RequireApproval.NEVER, + rollbackConfiguration: { + triggers: [{ arn: 'arn:aws:sns:bermuda-triangle-1:123456789012:not-an-alarm' }], + }, + })).rejects.toThrow(/not a valid CloudWatch alarm arn/); + }); + test('--no-rollback is forwarded to deployStack', async () => { await toolkit.deploy({ selector: selectExact('Test-Stack-A-Display-Name'),