Skip to content

Commit 465da31

Browse files
committed
fix(cli): externally managed stack notification arns are deleted on deploy (#32163)
Closes #32153. ### Reason for this change When a stack contains externally managed notification ARNs (i.e ones that were added outside of CDK), a `cdk deploy` command will remove those ARNs. ### Description of changes We incorrectly default notification ARNs to `[]` instead of `undefined`. When an empty array is passed to the SDK , it reasonably assumes you want to delete existing ARNs (because how otherwise would you delete them). If on the other hand you don't pass notification ARNs at all to the SDK (e.g `undefined`), it will preserve them. This is the correct behavior and CDK should follow suite. This does however create a (maybe) quirky API ergonomic where in order to remove notification ARNs, one must pass `[]` instead of simply omitting the property. This stems from the fact notification ARNs are not managed through the template, but rather through imperative actions. So it seems reasonable al things considered. ### Description of how you validated changes Added both unit and integration tests. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent a0b47c5 commit 465da31

File tree

11 files changed

+205
-27
lines changed

11 files changed

+205
-27
lines changed

allowed-breaking-changes.txt

+7-1
Original file line numberDiff line numberDiff line change
@@ -936,4 +936,10 @@ removed:aws-cdk-lib.aws_ec2.WindowsVersion.WINDOWS_SERVER_2022_TURKISH_FULL_BASE
936936

937937
# null() return a [] which is invalid filter rule. It should return [null].
938938
# Hence the return type changes from string[] to any
939-
change-return-type:aws-cdk-lib.aws_lambda.FilterRule.null
939+
change-return-type:aws-cdk-lib.aws_lambda.FilterRule.null
940+
941+
942+
# output property was mistakenly marked as required even though it should have allowed
943+
# for undefined, i.e optional
944+
changed-type:@aws-cdk/cx-api.CloudFormationStackArtifact.notificationArns
945+
changed-type:aws-cdk-lib.cx_api.CloudFormationStackArtifact.notificationArns

packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js

+15-6
Original file line numberDiff line numberDiff line change
@@ -742,12 +742,23 @@ class BuiltinLambdaStack extends cdk.Stack {
742742
}
743743
}
744744

745-
class NotificationArnPropStack extends cdk.Stack {
745+
class NotificationArnsStack extends cdk.Stack {
746746
constructor(parent, id, props) {
747-
super(parent, id, props);
748-
new sns.Topic(this, 'topic');
747+
748+
const arnsFromEnv = process.env.INTEG_NOTIFICATION_ARNS;
749+
super(parent, id, {
750+
...props,
751+
// comma separated list of arns.
752+
// empty string means empty list.
753+
// undefined means undefined
754+
notificationArns: arnsFromEnv == '' ? [] : (arnsFromEnv ? arnsFromEnv.split(',') : undefined)
755+
});
756+
757+
new cdk.CfnWaitConditionHandle(this, 'WaitConditionHandle');
758+
749759
}
750760
}
761+
751762
class AppSyncHotswapStack extends cdk.Stack {
752763
constructor(parent, id, props) {
753764
super(parent, id, props);
@@ -839,9 +850,7 @@ switch (stackSet) {
839850
new DockerInUseStack(app, `${stackPrefix}-docker-in-use`);
840851
new DockerStackWithCustomFile(app, `${stackPrefix}-docker-with-custom-file`);
841852

842-
new NotificationArnPropStack(app, `${stackPrefix}-notification-arn-prop`, {
843-
notificationArns: [`arn:aws:sns:${defaultEnv.region}:${defaultEnv.account}:${stackPrefix}-test-topic-prop`],
844-
});
853+
new NotificationArnsStack(app, `${stackPrefix}-notification-arns`);
845854

846855
// SSO stacks
847856
new SsoInstanceAccessControlConfig(app, `${stackPrefix}-sso-access-control`);

packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts

+138-4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
DescribeStacksCommand,
88
GetTemplateCommand,
99
ListChangeSetsCommand,
10+
UpdateStackCommand,
11+
waitUntilStackUpdateComplete,
1012
} from '@aws-sdk/client-cloudformation';
1113
import { DescribeServicesCommand } from '@aws-sdk/client-ecs';
1214
import {
@@ -633,14 +635,14 @@ integTest(
633635
const topicArn = response.TopicArn!;
634636

635637
try {
636-
await fixture.cdkDeploy('test-2', {
638+
await fixture.cdkDeploy('notification-arns', {
637639
options: ['--notification-arns', topicArn],
638640
});
639641

640642
// verify that the stack we deployed has our notification ARN
641643
const describeResponse = await fixture.aws.cloudFormation.send(
642644
new DescribeStacksCommand({
643-
StackName: fixture.fullStackName('test-2'),
645+
StackName: fixture.fullStackName('notification-arns'),
644646
}),
645647
);
646648
expect(describeResponse.Stacks?.[0].NotificationARNs).toEqual([topicArn]);
@@ -661,12 +663,63 @@ integTest('deploy with notification ARN as prop', withDefaultFixture(async (fixt
661663
const topicArn = response.TopicArn!;
662664

663665
try {
664-
await fixture.cdkDeploy('notification-arn-prop');
666+
await fixture.cdkDeploy('notification-arns', {
667+
modEnv: {
668+
INTEG_NOTIFICATION_ARNS: topicArn,
669+
670+
},
671+
});
665672

666673
// verify that the stack we deployed has our notification ARN
667674
const describeResponse = await fixture.aws.cloudFormation.send(
668675
new DescribeStacksCommand({
669-
StackName: fixture.fullStackName('notification-arn-prop'),
676+
StackName: fixture.fullStackName('notification-arns'),
677+
}),
678+
);
679+
expect(describeResponse.Stacks?.[0].NotificationARNs).toEqual([topicArn]);
680+
} finally {
681+
await fixture.aws.sns.send(
682+
new DeleteTopicCommand({
683+
TopicArn: topicArn,
684+
}),
685+
);
686+
}
687+
}));
688+
689+
// https://github.com/aws/aws-cdk/issues/32153
690+
integTest('deploy preserves existing notification arns when not specified', withDefaultFixture(async (fixture) => {
691+
const topicName = `${fixture.stackNamePrefix}-topic`;
692+
693+
const response = await fixture.aws.sns.send(new CreateTopicCommand({ Name: topicName }));
694+
const topicArn = response.TopicArn!;
695+
696+
try {
697+
await fixture.cdkDeploy('notification-arns');
698+
699+
// add notification arns externally to cdk
700+
await fixture.aws.cloudFormation.send(
701+
new UpdateStackCommand({
702+
StackName: fixture.fullStackName('notification-arns'),
703+
UsePreviousTemplate: true,
704+
NotificationARNs: [topicArn],
705+
}),
706+
);
707+
708+
await waitUntilStackUpdateComplete(
709+
{
710+
client: fixture.aws.cloudFormation,
711+
maxWaitTime: 600,
712+
},
713+
{ StackName: fixture.fullStackName('notification-arns') },
714+
);
715+
716+
// deploy again
717+
await fixture.cdkDeploy('notification-arns');
718+
719+
// make sure the notification arn is preserved
720+
const describeResponse = await fixture.aws.cloudFormation.send(
721+
new DescribeStacksCommand({
722+
StackName: fixture.fullStackName('notification-arns'),
670723
}),
671724
);
672725
expect(describeResponse.Stacks?.[0].NotificationARNs).toEqual([topicArn]);
@@ -679,6 +732,87 @@ integTest('deploy with notification ARN as prop', withDefaultFixture(async (fixt
679732
}
680733
}));
681734

735+
integTest('deploy deletes ALL notification arns when empty array is passed', withDefaultFixture(async (fixture) => {
736+
const topicName = `${fixture.stackNamePrefix}-topic`;
737+
738+
const response = await fixture.aws.sns.send(new CreateTopicCommand({ Name: topicName }));
739+
const topicArn = response.TopicArn!;
740+
741+
try {
742+
await fixture.cdkDeploy('notification-arns', {
743+
modEnv: {
744+
INTEG_NOTIFICATION_ARNS: topicArn,
745+
},
746+
});
747+
748+
// make sure the arn was added
749+
let describeResponse = await fixture.aws.cloudFormation.send(
750+
new DescribeStacksCommand({
751+
StackName: fixture.fullStackName('notification-arns'),
752+
}),
753+
);
754+
expect(describeResponse.Stacks?.[0].NotificationARNs).toEqual([topicArn]);
755+
756+
// deploy again with empty array
757+
await fixture.cdkDeploy('notification-arns', {
758+
modEnv: {
759+
INTEG_NOTIFICATION_ARNS: '',
760+
},
761+
});
762+
763+
// make sure the arn was deleted
764+
describeResponse = await fixture.aws.cloudFormation.send(
765+
new DescribeStacksCommand({
766+
StackName: fixture.fullStackName('notification-arns'),
767+
}),
768+
);
769+
expect(describeResponse.Stacks?.[0].NotificationARNs).toEqual([]);
770+
} finally {
771+
await fixture.aws.sns.send(
772+
new DeleteTopicCommand({
773+
TopicArn: topicArn,
774+
}),
775+
);
776+
}
777+
}));
778+
779+
integTest('deploy with notification ARN as prop and flag', withDefaultFixture(async (fixture) => {
780+
const topic1Name = `${fixture.stackNamePrefix}-topic1`;
781+
const topic2Name = `${fixture.stackNamePrefix}-topic1`;
782+
783+
const topic1Arn = (await fixture.aws.sns.send(new CreateTopicCommand({ Name: topic1Name }))).TopicArn!;
784+
const topic2Arn = (await fixture.aws.sns.send(new CreateTopicCommand({ Name: topic2Name }))).TopicArn!;
785+
786+
try {
787+
await fixture.cdkDeploy('notification-arns', {
788+
modEnv: {
789+
INTEG_NOTIFICATION_ARNS: topic1Arn,
790+
791+
},
792+
options: ['--notification-arns', topic2Arn],
793+
});
794+
795+
// verify that the stack we deployed has our notification ARN
796+
const describeResponse = await fixture.aws.cloudFormation.send(
797+
new DescribeStacksCommand({
798+
StackName: fixture.fullStackName('notification-arns'),
799+
}),
800+
);
801+
expect(describeResponse.Stacks?.[0].NotificationARNs).toEqual([topic1Arn, topic2Arn]);
802+
} finally {
803+
await fixture.aws.sns.send(
804+
new DeleteTopicCommand({
805+
TopicArn: topic1Arn,
806+
}),
807+
);
808+
await fixture.aws.sns.send(
809+
new DeleteTopicCommand({
810+
TopicArn: topic2Arn,
811+
}),
812+
);
813+
}
814+
}));
815+
682816
// NOTE: this doesn't currently work with modern-style synthesis, as the bootstrap
683817
// role by default will not have permission to iam:PassRole the created role.
684818
integTest(

packages/aws-cdk-lib/core/README.md

+10-1
Original file line numberDiff line numberDiff line change
@@ -1332,7 +1332,16 @@ const stack = new Stack(app, 'StackName', {
13321332
});
13331333
```
13341334

1335-
Stack events will be sent to any SNS Topics in this list.
1335+
Stack events will be sent to any SNS Topics in this list. These ARNs are added to those specified using
1336+
the `--notification-arns` command line option.
1337+
1338+
Note that in order to do delete notification ARNs entirely, you must pass an empty array ([]) instead of omitting it.
1339+
If you omit the property, no action on existing ARNs will take place.
1340+
1341+
> [!NOTICE]
1342+
> Adding the `notificationArns` property (or using the `--notification-arns` CLI options) will **override**
1343+
> any existing ARNs configured on the stack. If you have an external system managing notification ARNs,
1344+
> either migrate to use this mechanism, or avoid specfying notification ARNs with the CDK.
13361345

13371346
### CfnJson
13381347

packages/aws-cdk-lib/core/lib/stack.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ export class Stack extends Construct implements ITaggable {
376376
*
377377
* @internal
378378
*/
379-
public readonly _notificationArns: string[];
379+
public readonly _notificationArns?: string[];
380380

381381
/**
382382
* Logical ID generation strategy
@@ -471,7 +471,7 @@ export class Stack extends Construct implements ITaggable {
471471
}
472472
}
473473

474-
this._notificationArns = props.notificationArns ?? [];
474+
this._notificationArns = props.notificationArns;
475475

476476
if (!VALID_STACK_NAME_REGEX.test(this.stackName)) {
477477
throw new Error(`Stack name must match the regular expression: ${VALID_STACK_NAME_REGEX.toString()}, got '${this.stackName}'`);

packages/aws-cdk-lib/core/test/stack.test.ts

+15
Original file line numberDiff line numberDiff line change
@@ -2097,6 +2097,21 @@ describe('stack', () => {
20972097
]);
20982098
});
20992099

2100+
test('stack notification arns defaults to undefined', () => {
2101+
2102+
const app = new App({ stackTraces: false });
2103+
const stack1 = new Stack(app, 'stack1', {});
2104+
2105+
const asm = app.synth();
2106+
2107+
// It must be undefined and not [] because:
2108+
//
2109+
// - undefined => cdk ignores it entirely, as if it wasn't supported (allows external management).
2110+
// - []: => cdk manages it, and the user wants to wipe it out.
2111+
// - ['arn-1'] => cdk manages it, and the user wants to set it to ['arn-1'].
2112+
expect(asm.getStackArtifact(stack1.artifactId).notificationArns).toBeUndefined();
2113+
});
2114+
21002115
test('stack notification arns are reflected in the stack artifact properties', () => {
21012116
// GIVEN
21022117
const NOTIFICATION_ARNS = ['arn:aws:sns:bermuda-triangle-1337:123456789012:MyTopic'];

packages/aws-cdk-lib/cx-api/lib/artifacts/cloudformation-artifact.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class CloudFormationStackArtifact extends CloudArtifact {
5656
/**
5757
* SNS Topics that will receive stack events.
5858
*/
59-
public readonly notificationArns: string[];
59+
public readonly notificationArns?: string[];
6060

6161
/**
6262
* The physical name of this stack.
@@ -174,7 +174,7 @@ export class CloudFormationStackArtifact extends CloudArtifact {
174174
// We get the tags from 'properties' if available (cloud assembly format >= 6.0.0), otherwise
175175
// from the stack metadata
176176
this.tags = properties.tags ?? this.tagsFromMetadata();
177-
this.notificationArns = properties.notificationArns ?? [];
177+
this.notificationArns = properties.notificationArns;
178178
this.assumeRoleArn = properties.assumeRoleArn;
179179
this.assumeRoleExternalId = properties.assumeRoleExternalId;
180180
this.assumeRoleAdditionalOptions = properties.assumeRoleAdditionalOptions;

packages/aws-cdk/lib/cdk-toolkit.ts

+13-8
Original file line numberDiff line numberDiff line change
@@ -370,15 +370,20 @@ export class CdkToolkit {
370370
}
371371
}
372372

373-
let notificationArns: string[] = [];
374-
notificationArns = notificationArns.concat(options.notificationArns ?? []);
375-
notificationArns = notificationArns.concat(stack.notificationArns);
376-
377-
notificationArns.map((arn) => {
378-
if (!validateSnsTopicArn(arn)) {
379-
throw new Error(`Notification arn ${arn} is not a valid arn for an SNS topic`);
373+
// Following are the same semantics we apply with respect to Notification ARNs (dictated by the SDK)
374+
//
375+
// - undefined => cdk ignores it, as if it wasn't supported (allows external management).
376+
// - []: => cdk manages it, and the user wants to wipe it out.
377+
// - ['arn-1'] => cdk manages it, and the user wants to set it to ['arn-1'].
378+
const notificationArns = (!!options.notificationArns || !!stack.notificationArns)
379+
? (options.notificationArns ?? []).concat(stack.notificationArns ?? [])
380+
: undefined;
381+
382+
for (const notificationArn of notificationArns ?? []) {
383+
if (!validateSnsTopicArn(notificationArn)) {
384+
throw new Error(`Notification arn ${notificationArn} is not a valid arn for an SNS topic`);
380385
}
381-
});
386+
}
382387

383388
const stackIndex = stacks.indexOf(stack) + 1;
384389
print('%s: deploying... [%s/%s]', chalk.bold(stack.displayName), stackIndex, stackCollection.stackCount);

packages/aws-cdk/lib/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export function makeConfig(): CliConfig {
112112
'build-exclude': { type: 'array', alias: 'E', nargs: 1, desc: 'Do not rebuild asset with the given ID. Can be specified multiple times', default: [] },
113113
'exclusively': { type: 'boolean', alias: 'e', desc: 'Only deploy requested stacks, don\'t include dependencies' },
114114
'require-approval': { type: 'string', choices: [RequireApproval.Never, RequireApproval.AnyChange, RequireApproval.Broadening], desc: 'What security-sensitive changes need manual approval' },
115-
'notification-arns': { type: 'array', desc: 'ARNs of SNS topics that CloudFormation will notify with stack related events', nargs: 1, requiresArg: true },
115+
'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.', nargs: 1, requiresArg: true },
116116
// @deprecated(v2) -- tags are part of the Cloud Assembly and tags specified here will be overwritten on the next deployment
117117
'tags': { type: 'array', alias: 't', desc: 'Tags to add to the stack (KEY=VALUE), overrides tags from Cloud Assembly (deprecated)', nargs: 1, requiresArg: true },
118118
'execute': { type: 'boolean', desc: 'Whether to execute ChangeSet (--no-execute will NOT execute the ChangeSet) (deprecated)', deprecated: true },

packages/aws-cdk/lib/parse-command-line-arguments.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ export function parseCommandLineArguments(
355355
})
356356
.option('notification-arns', {
357357
type: 'array',
358-
desc: 'ARNs of SNS topics that CloudFormation will notify with stack related events',
358+
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.",
359359
nargs: 1,
360360
requiresArg: true,
361361
})

packages/aws-cdk/test/cdk-toolkit.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1658,7 +1658,7 @@ class FakeCloudFormation extends Deployments {
16581658
.map(([Key, Value]) => ({ Key, Value }))
16591659
.sort((l, r) => l.Key.localeCompare(r.Key));
16601660
}
1661-
this.expectedNotificationArns = expectedNotificationArns ?? [];
1661+
this.expectedNotificationArns = expectedNotificationArns;
16621662
}
16631663

16641664
public deployStack(options: DeployStackOptions): Promise<SuccessfulDeployStackResult> {

0 commit comments

Comments
 (0)